@xema/omni-protocol 0.1.82 → 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
@@ -2044,6 +2285,36 @@ export function validateTaskCommand(command, task, path = "command", taskContext
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":
@@ -2141,8 +2412,8 @@ export function validateTaskCommand(command, task, path = "command", taskContext
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) => {
@@ -2327,6 +2598,8 @@ export function validateResult(result, method, path = "result", dialId) {
2327
2598
  else {
2328
2599
  into.require(result.dialId === undefined, "result.dialId.unexpected", `${path}.dialId`, `${statuses.success} dialled nothing and names no dial`);
2329
2600
  }
2601
+ if (method === "recordStep")
2602
+ into.timestamp(result.at, "result.recordStep.at", `${path}.at`);
2330
2603
  }
2331
2604
  else if (statuses.failure !== undefined && result.status === statuses.failure) {
2332
2605
  if (result.failure === undefined) {