@xema/omni-protocol 0.1.81 → 0.1.83

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.
@@ -85,7 +85,7 @@ const MEMBER_BREAKS = membersOf({
85
85
  const OFFERABLE_PHASES = membersOf({
86
86
  pending: true,
87
87
  });
88
- const PREVIEW_DEADLINES = membersOf({ calls: true, expires: true });
88
+ const PREVIEW_DEADLINES = membersOf({ calls: true, "host-calls": true, waits: true });
89
89
  const PHONES = membersOf({ softphone: true, deskPhone: true });
90
90
  const TEAM_CAPABILITIES = membersOf({ breakControl: true, leadAssistControl: true, policyControl: true, monitorControl: true });
91
91
  const MONITOR_MODES = membersOf({ monitor: true, whisper: true, barge: true });
@@ -395,6 +395,78 @@ export function validatePhone(value, manifest, path = "context.phone") {
395
395
  function manifestDials(manifest) {
396
396
  return isPlainObject(manifest) ? Array.isArray(manifest.dialOutcomes) : undefined;
397
397
  }
398
+ /** Undefined explicitly means unavailable; a returned estimate is not a clock guarantee. */
399
+ export function validateProviderTimeEstimate(value, scope, path = "estimate") {
400
+ const into = new Collector();
401
+ if (!isPlainObject(scope)) {
402
+ into.add("timeEstimate.scope", path, "provider/login scope is required");
403
+ return into.violations;
404
+ }
405
+ for (const key of ["providerId", "loginId"])
406
+ into.filled(scope[key], "timeEstimate.scope", `scope.${key}`, "explicit provider/login scope is required");
407
+ if (value === undefined)
408
+ return into.violations;
409
+ if (!isPlainObject(value)) {
410
+ into.add("timeEstimate.shape", path, "expected a provider time estimate or undefined");
411
+ return into.violations;
412
+ }
413
+ for (const key of ["providerId", "loginId"])
414
+ into.require(value[key] === scope[key], "timeEstimate.scope", `${path}.${key}`, "estimate must match the requested provider/login");
415
+ into.timestamp(value.at, "timeEstimate.at", `${path}.at`);
416
+ into.filled(value.clockId, "timeEstimate.clock", `${path}.clockId`, "estimate must identify its clock domain");
417
+ if (value.uncertaintyMs !== undefined)
418
+ into.require(typeof value.uncertaintyMs === "number" && Number.isFinite(value.uncertaintyMs) && value.uncertaintyMs >= 0, "timeEstimate.uncertainty", `${path}.uncertaintyMs`, "estimated uncertainty must be finite and nonnegative");
419
+ for (const key of Object.keys(value))
420
+ into.require(["providerId", "loginId", "at", "clockId", "uncertaintyMs"].includes(key), "timeEstimate.field", `${path}.${key}`, "unsupported estimate field");
421
+ return into.violations;
422
+ }
423
+ /** Validate host-local periodic clock-check configuration. Omission disables checking. */
424
+ export function validateProviderTimeCheckPolicy(value, path = "timeCheck") {
425
+ const into = new Collector();
426
+ if (value === undefined)
427
+ return into.violations;
428
+ if (!isPlainObject(value)) {
429
+ into.add("timeCheck.policy.shape", path, "expected an explicit clock-check policy");
430
+ return into.violations;
431
+ }
432
+ for (const field of ["intervalMs", "timeoutMs", "maxRoundTripMs", "maxSampleAgeMs"]) {
433
+ into.require(typeof value[field] === "number" && Number.isSafeInteger(value[field]) && value[field] > 0, "timeCheck.policy.duration", `${path}.${field}`, "expected positive safe integer milliseconds");
434
+ }
435
+ for (const key of Object.keys(value))
436
+ into.require(["intervalMs", "timeoutMs", "maxRoundTripMs", "maxSampleAgeMs"].includes(key), "timeCheck.policy.field", `${path}.${key}`, "unsupported policy field");
437
+ if (!into.violations.length)
438
+ into.require(value.maxRoundTripMs <= value.timeoutMs && value.timeoutMs <= value.intervalMs, "timeCheck.policy.order", path, "maxRoundTripMs <= timeoutMs <= intervalMs is required");
439
+ return into.violations;
440
+ }
441
+ export function validateProviderTimeCheckRequest(value, path = "request") {
442
+ const into = new Collector();
443
+ if (!isPlainObject(value)) {
444
+ into.add("timeCheck.request.shape", path, "expected a clock-check request");
445
+ return into.violations;
446
+ }
447
+ into.filled(value.requestId, "timeCheck.request.id", `${path}.requestId`, "a check needs a fresh request ID");
448
+ for (const key of Object.keys(value))
449
+ into.require(key === "requestId", "timeCheck.request.field", `${path}.${key}`, "unsupported request field");
450
+ return into.violations;
451
+ }
452
+ /** Checks response shape/correlation, not source-clock accuracy, round-trip delay or freshness. */
453
+ export function validateProviderTimeCheckResult(value, request, loginId, path = "result") {
454
+ const into = new Collector();
455
+ into.violations.push(...validateProviderTimeCheckRequest(request));
456
+ into.filled(loginId, "timeCheck.login", "loginId", "current authenticated login is required");
457
+ if (!isPlainObject(value)) {
458
+ into.add("timeCheck.result.shape", path, "expected a clock-check response");
459
+ return into.violations;
460
+ }
461
+ into.filled(value.requestId, "timeCheck.result.id", `${path}.requestId`, "response must identify its request");
462
+ into.require(isPlainObject(request) && value.requestId === request.requestId, "timeCheck.result.request", `${path}.requestId`, "response must match the outstanding check");
463
+ into.require(value.loginId === loginId, "timeCheck.result.login", `${path}.loginId`, "response belongs to the current login");
464
+ into.filled(value.clockId, "timeCheck.result.clock", `${path}.clockId`, "provider clock domain/incarnation is required");
465
+ into.timestamp(value.providerTime, "timeCheck.result.time", `${path}.providerTime`);
466
+ for (const key of Object.keys(value))
467
+ into.require(["requestId", "loginId", "clockId", "providerTime"].includes(key), "timeCheck.result.field", `${path}.${key}`, "unsupported response field");
468
+ return into.violations;
469
+ }
398
470
  export function validateManifest(manifest, path = "manifest") {
399
471
  const into = new Collector();
400
472
  if (!isPlainObject(manifest)) {
@@ -430,6 +502,10 @@ export function validateManifest(manifest, path = "manifest") {
430
502
  }
431
503
  if (channelValid)
432
504
  validateIdleCapabilities(manifest.idleCapabilities, manifest.channel, `${path}.idleCapabilities`, into);
505
+ if (manifest.timestampAuthority !== undefined)
506
+ into.require(manifest.timestampAuthority === "provider", "manifest.timestampAuthority", `${path}.timestampAuthority`, "provider timestamp authority must be explicit; omission makes no trust promise");
507
+ if (manifest.timeCheck !== undefined)
508
+ into.require(manifest.timeCheck === true, "manifest.timeCheck", `${path}.timeCheck`, "declare true or omit unsupported clock checks");
433
509
  validateDialOutcomes(manifest, `${path}.dialOutcomes`, into);
434
510
  validatePhones(manifest, `${path}.phones`, into);
435
511
  if (manifest.phaseLabels !== undefined) {
@@ -853,7 +929,7 @@ function validateHandlingHistory(value, path, into, task = {}) {
853
929
  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`);
854
930
  }
855
931
  if (entry.step === "muted") {
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");
932
+ into.require(task.media !== "ended" && task.phase !== "completing", "task.handlingHistory.muted.open", `${at}.seconds`, "a muted entry without seconds is still running, but this handling's media has ended: the provider closes its open leg and states the duration; other channels may continue");
857
933
  }
858
934
  }
859
935
  // A muted entry carries whose the silence was, as the host reported it; no other step has it.
@@ -885,9 +961,9 @@ function validateHandlingHistory(value, path, into, task = {}) {
885
961
  /** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
886
962
  const TASK_MEDIA_STATES = membersOf({ started: true, ended: true });
887
963
  /** Real-time media is a voice affair, and its state is one of two words. */
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");
964
+ /** A host dial, or explicitly provider-triggered preview dial, is ringing the party. */
965
+ function partyRingingBeforeWork(task) {
966
+ return Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && entry.role === "party" && entry.stage === "ringing" && (isFilled(entry.dialId) || (task.phase === "preview" && task.atDeadline === "calls" && entry.dialId === undefined)));
891
967
  }
892
968
  function validateTaskMedia(task, value, channel, phase, path, into) {
893
969
  if (value === undefined)
@@ -897,9 +973,9 @@ function validateTaskMedia(task, value, channel, phase, path, into) {
897
973
  if (into.oneOf(value, TASK_MEDIA_STATES, "task.media", path)) {
898
974
  // Nothing is acquired while pending, and a preview has placed no call: media names a task whose
899
975
  // work has begun, on a snapshot as on the event, or a host would open the microphone on an offer.
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`);
976
+ // Ring-back may precede answer for a host dial or an explicitly provider-triggered preview.
977
+ // A deadline alone is never evidence that media started.
978
+ into.require(!WORK_NOT_BEGUN.includes(phase) || partyRingingBeforeWork(task), "task.media.beforeWork", path, `a ${describeValue(phase)} task has no media: audio arrives once its work has begun, or once an evidenced host/provider preview dial is ringing its party`);
903
979
  }
904
980
  }
905
981
  const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
@@ -1063,7 +1139,7 @@ function validateTaskInto(task, context, path, into) {
1063
1139
  into.timestamp(task.previewEndsAt, "task.preview.previewEndsAt", `${path}.previewEndsAt`);
1064
1140
  }
1065
1141
  if (task.atDeadline === undefined) {
1066
- into.add("task.preview.atDeadline.required", `${path}.atDeadline`, "a preview with a deadline says what the system does at it: calls, or expires -- an agent counting down has to know which");
1142
+ into.add("task.preview.atDeadline.required", `${path}.atDeadline`, "a preview with a deadline says what happens at it: provider calls, host calls, or waits for the agent -- an agent counting down has to know which");
1067
1143
  }
1068
1144
  else {
1069
1145
  into.oneOf(task.atDeadline, PREVIEW_DEADLINES, "task.preview.atDeadline", `${path}.atDeadline`);
@@ -1131,17 +1207,14 @@ function validateTaskInto(task, context, path, into) {
1131
1207
  validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
1132
1208
  validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into, { phase: task.phase, media: task.media });
1133
1209
  validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
1134
- // The room is who is on the call now, and a task outlives its call by the whole of wrap-up: a
1135
- // task whose call has ended -- completing, or media ended -- carries nobody, or the last thing the
1136
- // wire said about the call stays true for ever. Empty and absent both say nobody.
1137
- // A completing task's call is over: its media has ended, or it never started. Audio still stated
1138
- // as started on a task in wrap-up is a call whose audio never ended -- the customer heard through
1139
- // the agent's notes -- and a task stating it contradicts itself.
1210
+ // Media and onCall describe this agent's handling, not the continuing caller journey.
1211
+ // Once the handling's media ends or it enters wrap, its live room is cleared. Other
1212
+ // channels may remain connected under another handling or in IVR/queue stages.
1140
1213
  if (task.phase === "completing" && task.media === "started") {
1141
- into.add("task.media.completing", `${path}.media`, "a completing task's media has ended or never started: the call is over, and media still started is audio that never ended");
1214
+ into.add("task.media.completing", `${path}.media`, "a completing task's handling media has ended or never started; it cannot still be started during wrap");
1142
1215
  }
1143
1216
  if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
1144
- 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`);
1217
+ into.add("task.onCall.ended", `${path}.onCall`, `this handling is no longer connected (${task.phase === "completing" ? "the task is completing" : "its media ended"}); clear its onCall view without implying that other channels ended`);
1145
1218
  }
1146
1219
  validateTaskMedia(task, task.media, context.channel, task.phase, `${path}.media`, into);
1147
1220
  validateLeadAssist(task.leadAssist, context.channel, `${path}.leadAssist`, into);
@@ -1235,6 +1308,184 @@ function validateImposedBreak(value, path, into) {
1235
1308
  into.add("break.imposed.endsAutomatically", `${path}.endsAutomatically`, "an imposed break must say whether it ends automatically");
1236
1309
  }
1237
1310
  }
1311
+ /** Checks break status against the full currently retained task set, after a transaction. */
1312
+ export function validateBreakStatus(state, tasks, path = "snapshot") {
1313
+ const into = new Collector();
1314
+ validateBreakState(state, `${path}.break`, into);
1315
+ into.require(Array.isArray(tasks), "break.tasks.shape", `${path}.tasks`, "the complete current task list is required");
1316
+ if (Array.isArray(tasks))
1317
+ tasks.forEach((task, index) => {
1318
+ into.require(isPlainObject(task), "break.task.shape", `${path}.tasks[${index}]`, "each retained task must be an object");
1319
+ });
1320
+ // A break in effect begins when the work ends, so it holds no task. A snapshot reporting both
1321
+ // describes a state the agent cannot be in, whichever half is stale. The one exception is a
1322
+ // lead listening to a call during a break that is work of another sort -- coaching,
1323
+ // administrative, training -- and on no other.
1324
+ // The converse: a committed break with nothing outstanding has begun. starting-after-task beside
1325
+ // no task is a break waiting on work that does not exist, and an empty list reading as "still
1326
+ // finishing" is the plausible nought.
1327
+ ruleEvaluated("break.starting-after-task.tasks");
1328
+ if (isPlainObject(state) && state.approval === "starting-after-task"
1329
+ && Array.isArray(tasks) && tasks.length === 0) {
1330
+ 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");
1331
+ }
1332
+ if (isPlainObject(state) && state.approval === "in-effect"
1333
+ && Array.isArray(tasks) && tasks.length > 0) {
1334
+ const listening = tasks.every((task) => isPlainObject(task) && task.monitoring !== undefined);
1335
+ if (!listening) {
1336
+ into.add("break.in-effect.tasks", `${path}.tasks`, "a break in effect holds no task: it begins when the work ends, and until then the state is starting-after-task");
1337
+ }
1338
+ else {
1339
+ const reasons = Array.isArray(state.reasons) ? state.reasons : [];
1340
+ const active = reasons.find((reason) => isPlainObject(reason) && reason.id === state.activeReasonId);
1341
+ const kind = isPlainObject(active) ? active.kind : undefined;
1342
+ 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)}`);
1343
+ }
1344
+ }
1345
+ return into.violations;
1346
+ }
1347
+ /**
1348
+ * Checks lead break dispatch using current authentication, transport, roster (`team`) and,
1349
+ * for place/release, the target's full `memberBreak`. The provider must authorize the target
1350
+ * and recheck the decision atomically; a roster is not authority to act after it has changed.
1351
+ */
1352
+ export function validateTeamBreakCommand(request, context, path = "teamBreakCommand") {
1353
+ const into = new Collector();
1354
+ if (!isPlainObject(context) || !isPlainObject(request) || !isPlainObject(request.command)) {
1355
+ into.add("team.break.command.shape", path, "a command and current authentication/transport/team context are required");
1356
+ return into.violations;
1357
+ }
1358
+ const auth = isPlainObject(context.authentication) ? context.authentication : {};
1359
+ into.violations.push(...validateAuthenticationState(auth, `${path}.authentication`));
1360
+ const caps = isPlainObject(auth.capabilities) ? auth.capabilities : {};
1361
+ into.require((auth.status === "authenticated" || auth.status === "refreshing") && context.transport === "active", "team.break.command.connection", path, "lead commands require a live login and active transport");
1362
+ into.require(isPlainObject(caps.team) && caps.team.breakControl === true, "team.break.command.capability", path, "the login must declare team.breakControl");
1363
+ const command = request.command;
1364
+ const allowed = {
1365
+ decide: ["type", "memberId", "decision", "reason"], policy: ["type", "policy"],
1366
+ place: ["type", "memberId", "reasonId", "reason"], release: ["type", "memberId"],
1367
+ };
1368
+ const fields = typeof command.type === "string" && Object.hasOwn(allowed, command.type) ? allowed[command.type] : undefined;
1369
+ if (!fields) {
1370
+ into.add("team.break.command.type", path, "unknown lead break command");
1371
+ return into.violations;
1372
+ }
1373
+ for (const key of Object.keys(request))
1374
+ into.require(key === "command", "team.break.request.field", `${path}.${key}`, "only command is supported");
1375
+ for (const key of Object.keys(command))
1376
+ into.require(fields.includes(key), "team.break.command.field", `${path}.${key}`, "field is not supported for this command");
1377
+ if (command.reason !== undefined)
1378
+ into.filled(command.reason, "team.break.command.reason", path, "reason must not be empty");
1379
+ if (command.type === "policy") {
1380
+ into.require(["ask", "auto-approve", "suspended"].includes(command.policy), "team.break.command.policy", path, "unknown break policy");
1381
+ return into.violations;
1382
+ }
1383
+ into.filled(command.memberId, "team.break.command.member", path, "name the target member");
1384
+ const team = isPlainObject(context.team) ? context.team : {};
1385
+ const members = Array.isArray(team.members) ? team.members : [];
1386
+ const member = members.find((m) => isPlainObject(m) && m.id === command.memberId);
1387
+ const self = isPlainObject(auth.identity) ? auth.identity.id : undefined;
1388
+ into.require(isPlainObject(member) && command.memberId !== self, "team.break.command.member", path, "the target must be another member of the current authorized roster");
1389
+ if (command.type === "decide") {
1390
+ into.require(command.decision === "granted" || command.decision === "denied", "team.break.command.decision", path, "decide granted or denied");
1391
+ into.require(isPlainObject(member) && member.break === "awaiting-decision", "team.break.command.awaiting", path, "decide only a currently awaiting-decision request");
1392
+ }
1393
+ else {
1394
+ validateBreakState(context.memberBreak, `${path}.memberBreak`, into);
1395
+ const state = isPlainObject(context.memberBreak) ? context.memberBreak : {};
1396
+ if (command.type === "release")
1397
+ into.require(state.imposed !== undefined && (state.approval === "in-effect" || state.approval === "starting-after-task"), "team.break.command.release", path, "release a currently imposed break");
1398
+ if (command.type === "place") {
1399
+ if (command.reasonId !== undefined)
1400
+ into.filled(command.reasonId, "team.break.command.reasonId", path, "reasonId must not be empty");
1401
+ const reasons = Array.isArray(state.reasons) ? state.reasons : [];
1402
+ into.require(state.reasons === undefined ? command.reasonId === undefined : reasons.some((r) => isPlainObject(r) && r.id === command.reasonId), "team.break.command.reasonId", path, "place uses the target's currently published reason codes");
1403
+ }
1404
+ }
1405
+ return into.violations;
1406
+ }
1407
+ /**
1408
+ * Validate immediately before dispatch against current provider state. Context must contain
1409
+ * the current authentication and transport. This cannot fence a concurrent backend change;
1410
+ * the provider must recheck atomically. Result validation never replaces authoritative state.
1411
+ */
1412
+ export function validateBreakCommand(method, request, state, context, path = "breakCommand") {
1413
+ const into = new Collector();
1414
+ validateBreakState(state, `${path}.state`, into);
1415
+ if (!isPlainObject(context)) {
1416
+ into.add("break.command.context", path, "current authentication and transport are required");
1417
+ return into.violations;
1418
+ }
1419
+ into.violations.push(...validateAuthenticationState(context.authentication, `${path}.authentication`));
1420
+ const auth = isPlainObject(context.authentication) ? context.authentication : {};
1421
+ into.require(auth.status === "authenticated" || auth.status === "refreshing", "break.command.authentication", path, "break commands require a live authenticated login");
1422
+ into.require(isPlainObject(auth.capabilities) && auth.capabilities.breaks === true, "break.command.capability", path, "the login must declare breaks");
1423
+ into.require(context.transport === "active", "break.command.transport", path, "break commands require active transport");
1424
+ if (!["requestBreak", "commitBreak", "cancelBreak", "endBreak"].includes(method)) {
1425
+ into.add("break.command.method", path, "unknown break method");
1426
+ return into.violations;
1427
+ }
1428
+ if (!isPlainObject(state) || into.violations.length)
1429
+ return into.violations;
1430
+ const approval = state.approval;
1431
+ if (method === "requestBreak") {
1432
+ into.require(approval === "not-requested", "break.command.request.pending", path, "a new request starts only from not-requested");
1433
+ if (!isPlainObject(request)) {
1434
+ into.add("break.request.shape", path, "requestBreak takes a request object");
1435
+ return into.violations;
1436
+ }
1437
+ for (const key of Object.keys(request))
1438
+ into.require(key === "reason" || key === "reasonId", "break.request.field", `${path}.${key}`, "a break request carries only reason and reasonId");
1439
+ for (const key of ["reason", "reasonId"])
1440
+ if (request[key] !== undefined)
1441
+ into.filled(request[key], `break.request.${key}`, `${path}.${key}`, "a supplied reason must not be empty");
1442
+ const reasons = Array.isArray(state.reasons) ? state.reasons : [];
1443
+ const selected = reasons.find((r) => isPlainObject(r) && r.id === request.reasonId);
1444
+ if (state.reasons !== undefined)
1445
+ into.require(selected !== undefined, "break.request.reasonId", path, "choose a currently published reasonId; free text is not a substitute");
1446
+ else
1447
+ into.require(request.reasonId === undefined, "break.request.reasonId.unexpected", path, "no reason codes were published");
1448
+ into.require(state.mayAsk === true || (isPlainObject(selected) && selected.alwaysAvailable === true), "break.request.mayAsk", path, "asking is disabled except for the selected alwaysAvailable reason");
1449
+ }
1450
+ else {
1451
+ into.require(request === undefined, "break.command.arguments", path, "this break method takes no arguments");
1452
+ if (method === "commitBreak")
1453
+ into.require(approval === "granted" || approval === "starting-after-task" || approval === "in-effect", "break.command.commit.grant", path, "commit requires a grant; repeated committed state is idempotent");
1454
+ if (method === "cancelBreak")
1455
+ into.require(approval === "awaiting-decision" || approval === "granted", "break.command.cancel.precommit", path, "cancel only a pre-commit request; a raced commit requires commit recovery");
1456
+ if (method === "endBreak") {
1457
+ into.require(approval === "starting-after-task" || approval === "in-effect", "break.command.end.started", path, "end a committed break, including a returning provider still finishing work");
1458
+ into.require(state.imposed === undefined, "break.command.end.imposed", path, "an agent cannot end an imposed break; an authorized lead releases it");
1459
+ }
1460
+ }
1461
+ return into.violations;
1462
+ }
1463
+ /**
1464
+ * Checks two complete break states within one provider/login's ordered event stream.
1465
+ * Call before replacing accepted state; report violations and reconcile on failure.
1466
+ * This is not a freshness check: validate a fresh authoritative snapshot separately and
1467
+ * use it as a new baseline, never as an event transition. Source ordering and connection
1468
+ * fencing remain required; neither timestamps nor approval rank identify a break attempt.
1469
+ */
1470
+ export function validateBreakTransition(before, after, path = "break") {
1471
+ const into = new Collector();
1472
+ validateBreakState(before, `${path}.before`, into);
1473
+ validateBreakState(after, `${path}.after`, into);
1474
+ if (into.violations.length || !isPlainObject(before) || !isPlainObject(after))
1475
+ return into.violations;
1476
+ const from = before.approval;
1477
+ const to = after.approval;
1478
+ const committed = to === "starting-after-task" || to === "in-effect";
1479
+ if (committed && (from === "not-requested" || from === "awaiting-decision") && after.imposed === undefined) {
1480
+ into.add("stream.breakState.commitBeforeGrant", `${path}.after.approval`, `${to} follows a commit, and a commit follows granted; the break stood at ${from}`);
1481
+ }
1482
+ const backwards = (from === "in-effect" && (to === "awaiting-decision" || to === "granted" || to === "starting-after-task")) ||
1483
+ (from === "starting-after-task" && (to === "awaiting-decision" || to === "granted")) ||
1484
+ (from === "granted" && to === "awaiting-decision");
1485
+ if (backwards)
1486
+ into.add("stream.breakState.backwards", `${path}.after.approval`, `a break does not go from ${from} back to ${to}; a new request passes through not-requested`);
1487
+ return into.violations;
1488
+ }
1238
1489
  function validateBreakState(value, path, into) {
1239
1490
  if (!isPlainObject(value)) {
1240
1491
  into.add("break.shape", path, "break state must be an object");
@@ -1417,7 +1668,6 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1417
1668
  && context.loginId !== undefined) {
1418
1669
  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}`);
1419
1670
  }
1420
- validateBreakState(snapshot.break, `${path}.break`, into);
1421
1671
  // The count is the provider's confirmation of how much work it answered with. Stated, never
1422
1672
  // inferred: an unanswered or blank state lacks it, and cannot pass as a confirmed empty.
1423
1673
  if (into.require(typeof snapshot.taskCount === "number" && Number.isInteger(snapshot.taskCount) && snapshot.taskCount >= 0, "snapshot.taskCount", `${path}.taskCount`, "a snapshot states its task count: a whole number, zero or more")
@@ -1459,32 +1709,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1459
1709
  }
1460
1710
  });
1461
1711
  }
1462
- // A break in effect begins when the work ends, so it holds no task. A snapshot reporting both
1463
- // describes a state the agent cannot be in, whichever half is stale. The one exception is a
1464
- // lead listening to a call during a break that is work of another sort -- coaching,
1465
- // administrative, training -- and on no other.
1466
- // The converse: a committed break with nothing outstanding has begun. starting-after-task beside
1467
- // no task is a break waiting on work that does not exist, and an empty list reading as "still
1468
- // finishing" is the plausible nought.
1469
- ruleEvaluated("break.starting-after-task.tasks");
1470
- if (isPlainObject(snapshot.break) && snapshot.break.approval === "starting-after-task"
1471
- && Array.isArray(snapshot.tasks) && snapshot.tasks.length === 0) {
1472
- 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");
1473
- }
1474
- if (isPlainObject(snapshot.break) && snapshot.break.approval === "in-effect"
1475
- && Array.isArray(snapshot.tasks) && snapshot.tasks.length > 0) {
1476
- const listening = snapshot.tasks.every((task) => isPlainObject(task) && task.monitoring !== undefined);
1477
- if (!listening) {
1478
- into.add("break.in-effect.tasks", `${path}.tasks`, "a break in effect holds no task: it begins when the work ends, and until then the state is starting-after-task");
1479
- }
1480
- else {
1481
- const state = snapshot.break;
1482
- const reasons = Array.isArray(state.reasons) ? state.reasons : [];
1483
- const active = reasons.find((reason) => isPlainObject(reason) && reason.id === state.activeReasonId);
1484
- const kind = isPlainObject(active) ? active.kind : undefined;
1485
- 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)}`);
1486
- }
1487
- }
1712
+ into.violations.push(...validateBreakStatus(snapshot.break, snapshot.tasks, path));
1488
1713
  // Presence is the permission, and it cuts both ways: data a provider never declared a
1489
1714
  // capability for is data Omni would show against a control the agent does not have.
1490
1715
  const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
@@ -2020,6 +2245,22 @@ export function validateHostReport(report, path = "host") {
2020
2245
  const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
2021
2246
  const LEAD_ASSIST_ACTIONS = ["request", "cancel", "take-over", "leave"];
2022
2247
  const CONFERENCE_ACTIONS = ["add", "remove"];
2248
+ /** Validate the exact handling/allocation target before provider command dispatch. */
2249
+ export function validateTaskCommandRequest(request, task, path = "request", taskContext) {
2250
+ const into = new Collector();
2251
+ if (!isPlainObject(request) || !isPlainObject(task)) {
2252
+ into.add("command.request.shape", path, "a request and the current published task are required");
2253
+ return into.violations;
2254
+ }
2255
+ into.filled(request.taskId, "command.request.taskId", `${path}.taskId`, "name the handling task");
2256
+ into.filled(request.allocationId, "command.request.allocationId", `${path}.allocationId`, "name its allocation");
2257
+ into.require(request.taskId === task.id, "command.request.taskId.mismatch", `${path}.taskId`, "the command must target this exact handling");
2258
+ into.require(request.allocationId === task.allocationId, "command.request.allocationId.mismatch", `${path}.allocationId`, "the command must target this exact allocation");
2259
+ for (const key of Object.keys(request))
2260
+ into.require(["taskId", "allocationId", "command"].includes(key), "command.request.field", `${path}.${key}`, "unsupported task command request field");
2261
+ into.violations.push(...validateTaskCommand(request.command, task, `${path}.command`, taskContext));
2262
+ return into.violations;
2263
+ }
2023
2264
  /**
2024
2265
  * What a command needs to be issuable, checked against the task it names: the capability the
2025
2266
  * guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
@@ -2027,7 +2268,7 @@ const CONFERENCE_ACTIONS = ["add", "remove"];
2027
2268
  * own shape is checked. A host validates before sending, and an adapter before acting: a command
2028
2269
  * for a control the task never offered is the host's error, and this names it.
2029
2270
  */
2030
- export function validateTaskCommand(command, task, path = "command") {
2271
+ export function validateTaskCommand(command, task, path = "command", taskContext) {
2031
2272
  const into = new Collector();
2032
2273
  if (!isPlainObject(command)) {
2033
2274
  into.add("command.shape", path, "a command must be an object");
@@ -2044,6 +2285,36 @@ export function validateTaskCommand(command, task, path = "command") {
2044
2285
  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`)) {
2045
2286
  return into.violations;
2046
2287
  }
2288
+ // Built-in commands carry only their declared fields; custom controls own their payload.
2289
+ // Recording already applies its action-specific field check below.
2290
+ if (type !== "custom" && type !== "recording") {
2291
+ let fields = ["type"];
2292
+ switch (type) {
2293
+ case "call":
2294
+ case "connect-back":
2295
+ fields.push("dialId");
2296
+ break;
2297
+ case "complete":
2298
+ fields.push("disposition", "notes");
2299
+ break;
2300
+ case "transfer":
2301
+ fields.push("action");
2302
+ if (command.action === "cold" || command.action === "warm")
2303
+ fields.push("dialId", "destinationId");
2304
+ break;
2305
+ case "lead-assist":
2306
+ fields.push("action");
2307
+ if (command.action === "request")
2308
+ fields.push("note");
2309
+ break;
2310
+ case "conference":
2311
+ fields.push("action", "destinationId");
2312
+ fields.push(command.action === "add" ? "dialId" : "party");
2313
+ break;
2314
+ }
2315
+ for (const key of Object.keys(command))
2316
+ into.require(fields.includes(key), "command.field", `${path}.${key}`, "field is not supported for this command");
2317
+ }
2047
2318
  const dial = (rule) => into.filled(command.dialId, rule, `${path}.dialId`, "a command that dials carries the host's dialId");
2048
2319
  switch (type) {
2049
2320
  case "call":
@@ -2100,7 +2371,7 @@ export function validateTaskCommand(command, task, path = "command") {
2100
2371
  // checking against a task nobody has -- so the task is validated first, and a command is held
2101
2372
  // only to a task that stands.
2102
2373
  const published = new Collector();
2103
- validateTaskInto(task, { channel: channel ?? "voice" }, `${path}.task`, published);
2374
+ validateTaskInto(task, { ...taskContext, channel: channel ?? "voice" }, `${path}.task`, published);
2104
2375
  if (published.violations.length > 0 || !isPlainObject(task)) {
2105
2376
  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`);
2106
2377
  return into.violations;
@@ -2141,8 +2412,8 @@ export function validateTaskCommand(command, task, path = "command") {
2141
2412
  };
2142
2413
  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)}`);
2143
2414
  // A control on the contact acts on a contact being handled. Before in-progress there is nothing
2144
- // to act on yet, and in completing the handling has ended: a call with nobody on it, a
2145
- // conversation closed. What a capability offers, the phase decides whether there is anything to use it on.
2415
+ // to act on yet, and in completing this agent's handling has ended. The caller and other
2416
+ // channels may continue elsewhere; this task's phase no longer permits these controls.
2146
2417
  const handling = () => inPhase("command.phase.handling", "in-progress", "paused");
2147
2418
  // A destination is one the directory offered: the id Omni sends is the id the provider published.
2148
2419
  const listed = (name) => {
@@ -2173,7 +2444,8 @@ export function validateTaskCommand(command, task, path = "command") {
2173
2444
  handling();
2174
2445
  break;
2175
2446
  case "recording": {
2176
- offered("recording");
2447
+ if (!offered("recording"))
2448
+ break;
2177
2449
  const policy = isPlainObject(capabilities.recording) ? capabilities.recording.provider : undefined;
2178
2450
  const state = isPlainObject(task.recording) ? task.recording.provider : undefined;
2179
2451
  into.violations.push(...validateRecordingCommandState(command, policy, state, path));
@@ -2326,6 +2598,8 @@ export function validateResult(result, method, path = "result", dialId) {
2326
2598
  else {
2327
2599
  into.require(result.dialId === undefined, "result.dialId.unexpected", `${path}.dialId`, `${statuses.success} dialled nothing and names no dial`);
2328
2600
  }
2601
+ if (method === "recordStep")
2602
+ into.timestamp(result.at, "result.recordStep.at", `${path}.at`);
2329
2603
  }
2330
2604
  else if (statuses.failure !== undefined && result.status === statuses.failure) {
2331
2605
  if (result.failure === undefined) {
@@ -2589,7 +2863,6 @@ export function validateAuthenticationState(state, path = "authentication", cont
2589
2863
  // Independent current recording evidence and dispatch validation.
2590
2864
  const object = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2591
2865
  const filled = (v) => typeof v === "string" && v.trim().length > 0;
2592
- const effects = ["retain", "discard"];
2593
2866
  function collector() {
2594
2867
  const violations = [];
2595
2868
  const check = (ok, rule, path, message) => {
@@ -2660,14 +2933,7 @@ export function validateRecordingPolicy(value, path = "capabilities.recording")
2660
2933
  for (const action of RECORDING_ACTIONS) {
2661
2934
  if (policy[action] === undefined)
2662
2935
  continue;
2663
- if (action === "cancel") {
2664
- const cancel = policy.cancel;
2665
- check(object(cancel) && effects.includes(cancel.effect), "cancel.effect", `${at}.cancel`, "cancel explicitly retains or discards this recording's captured audio");
2666
- if (object(cancel))
2667
- keys(cancel, ["effect"], `${at}.cancel`);
2668
- }
2669
- else
2670
- check(policy[action] === true, "policy.action", `${at}.${action}`, "permission is true or absent");
2936
+ check(policy[action] === true, "policy.action", `${at}.${action}`, "permission is true or absent");
2671
2937
  }
2672
2938
  if (source === "host")
2673
2939
  check(filled(policy.destinationId), "destination", `${at}.destinationId`, "host storage destination must be explicit per task");
@@ -2683,8 +2949,10 @@ export function validateHostRecording(value, softphone, path = "host.recording")
2683
2949
  check(false, "host.shape", path, "expected host recording declaration and executor");
2684
2950
  return violations;
2685
2951
  }
2686
- keys(value, ["actions", "cancelEffects", "destinationIds", "execute"], path);
2687
- for (const [key, allowed] of [["actions", RECORDING_ACTIONS], ["cancelEffects", effects], ["destinationIds", undefined]]) {
2952
+ keys(value, ["actions", "destinationIds", "execute", "announcesToCaller"], path);
2953
+ if (value.announcesToCaller !== undefined)
2954
+ check(value.announcesToCaller === true, "host.announcesToCaller", `${path}.announcesToCaller`, "caller announcement guarantee is true or absent");
2955
+ for (const [key, allowed] of [["actions", RECORDING_ACTIONS], ["destinationIds", undefined]]) {
2688
2956
  const list = value[key];
2689
2957
  if (!Array.isArray(list)) {
2690
2958
  check(false, "host.list", `${path}.${key}`, "expected explicit array");
@@ -2692,11 +2960,9 @@ export function validateHostRecording(value, softphone, path = "host.recording")
2692
2960
  }
2693
2961
  check(new Set(list).size === list.length, "host.duplicate", `${path}.${key}`, "duplicate declaration");
2694
2962
  check(list.every(item => filled(item) && (allowed === undefined || allowed.includes(item))), "host.list", `${path}.${key}`, "unsupported or empty declaration");
2695
- if (key !== "cancelEffects")
2696
- check(list.length > 0, "host.empty", `${path}.${key}`, "declare at least one supported value");
2963
+ check(list.length > 0, "host.empty", `${path}.${key}`, "declare at least one supported value");
2697
2964
  }
2698
- if (Array.isArray(value.actions) && Array.isArray(value.cancelEffects)) {
2699
- check(value.actions.includes("cancel") === (value.cancelEffects.length > 0), "host.cancel", path, "cancel support and explicit effects must agree");
2965
+ if (Array.isArray(value.actions)) {
2700
2966
  check(value.actions.includes("pause") === value.actions.includes("resume"), "host.pause", path, "a pausable host supports resume; task permissions may independently restrict it");
2701
2967
  }
2702
2968
  check(typeof value.execute === "function", "host.execute", `${path}.execute`, "host recording requires its own executor");
@@ -2732,32 +2998,47 @@ export function validateRecordingCommandShape(value, source, path = "command") {
2732
2998
  check(false, "command.shape", path, "expected recording command");
2733
2999
  return violations;
2734
3000
  }
2735
- keys(value, ["type", "source", "requestId", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"]), ...(value.action === "cancel" ? ["cancelEffect"] : [])], path);
3001
+ keys(value, ["type", "source", "requestId", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])], path);
2736
3002
  check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
2737
3003
  check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
2738
3004
  for (const key of ["requestId", "observationId", ...(value.action === "start" ? [] : ["recordingId"])])
2739
3005
  check(filled(value[key]), "command.identity", `${path}.${key}`, "expected nonempty scoped identity");
2740
- if (value.action === "cancel")
2741
- check(effects.includes(value.cancelEffect), "cancel.effect", `${path}.cancelEffect`, "explicit captured-audio disposition required");
2742
3006
  return violations;
2743
3007
  }
3008
+ /** One transition table shared by dispatch checks and outcome checks. */
3009
+ const RECORDING_TRANSITIONS = {
3010
+ start: { from: ["inactive"], to: "active" },
3011
+ pause: { from: ["active"], to: "paused" },
3012
+ resume: { from: ["paused"], to: "active" },
3013
+ stop: { from: ["active", "paused"], to: "inactive" },
3014
+ cancel: { from: ["active", "paused"], to: "inactive" },
3015
+ };
3016
+ function recordingTransition(action) {
3017
+ return typeof action === "string" && Object.hasOwn(RECORDING_TRANSITIONS, action)
3018
+ ? RECORDING_TRANSITIONS[action] : undefined;
3019
+ }
2744
3020
  /** Structural/state permission check. Dispatch MUST also call validateRecordingRequest for scope and freshness. */
2745
3021
  export function validateRecordingCommandState(command, policy, state, path = "command") {
2746
3022
  const { violations, check } = collector();
2747
- if (!object(command))
3023
+ if (!object(command)) {
3024
+ check(false, "command.shape", path, "expected recording command");
3025
+ return violations;
3026
+ }
3027
+ const transition = recordingTransition(command.action);
3028
+ if (transition === undefined) {
3029
+ check(false, "command.action", `${path}.action`, "unsupported recording action");
2748
3030
  return violations;
2749
- check(object(policy) && policy[command.action] !== undefined, "command.permission", path, "task does not permit this action on this recorder");
3031
+ }
3032
+ const permitted = object(policy) && policy[command.action] === true;
3033
+ check(permitted, "command.permission", path, "task does not permit this action on this recorder");
2750
3034
  if (!object(state) || validateRecordingState(state).length) {
2751
3035
  check(false, "command.state", path, "no validated current recorder observation");
2752
3036
  return violations;
2753
3037
  }
2754
- const allowed = command.action === "start" ? ["inactive"] : command.action === "pause" ? ["active"] : command.action === "resume" ? ["paused"] : ["active", "paused"];
2755
- check(allowed.includes(state.status), "command.state", path, "action is invalid in the observed recorder state");
3038
+ check(transition.from.includes(state.status), "command.state", path, "action is invalid in the observed recorder state");
2756
3039
  check(command.observationId === state.observationId, "command.stale", path, "observation changed; reconcile before acting");
2757
3040
  if (command.action !== "start")
2758
3041
  check(command.recordingId === state.recordingId, "command.recordingId", path, "command targets another recording");
2759
- if (command.action === "cancel" && object(policy))
2760
- check(object(policy.cancel) && command.cancelEffect === policy.cancel.effect, "command.cancelEffect", path, "cancel disposition must match task policy");
2761
3042
  return violations;
2762
3043
  }
2763
3044
  /** Validate immediately before dispatch using current task, host report and trusted observer-domain time. */
@@ -2767,7 +3048,7 @@ export function validateRecordingRequest(request, task, context, path = "request
2767
3048
  check(false, "request.shape", path, "request and current task required");
2768
3049
  return violations;
2769
3050
  }
2770
- violations.push(...validateTask(task, { channel: "voice" }, `${path}.task`));
3051
+ violations.push(...validateTask(task, { ...context.taskContext, channel: "voice" }, `${path}.task`));
2771
3052
  keys(request, ["taskId", "allocationId", "command"], path);
2772
3053
  check(filled(request.taskId) && filled(request.allocationId) && request.taskId === task.id && request.allocationId === task.allocationId, "request.scope", path, "task allocation changed or scope missing");
2773
3054
  check(task.channel === "voice", "request.channel", path, "recording is voice-only");
@@ -2788,8 +3069,6 @@ export function validateRecordingRequest(request, task, context, path = "request
2788
3069
  if (object(context.host)) {
2789
3070
  check(Array.isArray(context.host.actions) && context.host.actions.includes(command.action), "host.action", path, "host does not support action");
2790
3071
  check(object(policy) && Array.isArray(context.host.destinationIds) && context.host.destinationIds.includes(policy.destinationId), "host.destination", path, "task destination is not provisioned by this host");
2791
- if (command.action === "cancel")
2792
- check(Array.isArray(context.host.cancelEffects) && context.host.cancelEffects.includes(command.cancelEffect), "host.cancelEffect", path, "host does not support cancel disposition");
2793
3072
  }
2794
3073
  }
2795
3074
  const fresh = effectiveRecordingState(state, context.now);
@@ -2819,12 +3098,13 @@ export function validateRecordingOutcome(command, before, after, result, path =
2819
3098
  check(before.status !== "unknown" && after.status === before.status && after.recordingId === before.recordingId, "outcome.noEffect", path, "failed promises confirmed no effect; ambiguous or partial effects have no result");
2820
3099
  }
2821
3100
  else if (result.status === "applied") {
2822
- const expected = command.action === "pause" ? "paused" : command.action === "start" || command.action === "resume" ? "active" : "inactive";
2823
- check(after.status === expected, "outcome.state", path, "applied requires the confirming requested state");
3101
+ const transition = recordingTransition(command.action);
3102
+ if (transition === undefined)
3103
+ return violations; // shape validation already reported the unknown action
3104
+ check(after.status === transition.to, "outcome.state", path, "applied requires the confirming requested state");
2824
3105
  check(after.observationId !== before.observationId, "outcome.observation", path, "an applied transition needs new confirming evidence");
2825
3106
  check(before.observationId === command.observationId, "outcome.stale", path, "command must act against its named observation");
2826
- const origin = command.action === "start" ? ["inactive"] : command.action === "pause" ? ["active"] : command.action === "resume" ? ["paused"] : ["active", "paused"];
2827
- check(origin.includes(before.status), "outcome.origin", path, "applied cannot claim an impossible transition");
3107
+ check(transition.from.includes(before.status), "outcome.origin", path, "applied cannot claim an impossible transition");
2828
3108
  if (command.action !== "start")
2829
3109
  check(command.recordingId === before.recordingId, "outcome.recordingId", path, "command must target the prior recording");
2830
3110
  if (command.action === "pause" || command.action === "resume")