@xema/omni-protocol 0.1.82 → 0.1.84

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/testing.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
2
- import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateDescribedUsers, observeRules, ruleEvaluated, validateHostMute, validateHostRecording, validateLoginStore, validateHandlingReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
2
+ import { assertNoViolations, validateAuthenticationState, validateBreakTransition, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateDescribedUsers, observeRules, ruleEvaluated, validateHostMute, validateHostRecording, validateLoginStore, validateInteractionReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
3
3
  export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
4
4
  /**
5
5
  * A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
@@ -11,7 +11,7 @@ const STATE_SUBJECTS = [
11
11
  "tasks",
12
12
  "task.browsers",
13
13
  "task.attributes",
14
- "task.handlingHistory",
14
+ "task.interactionHistory",
15
15
  "task.onCall",
16
16
  "task.leadAssist",
17
17
  "task.assisting",
@@ -53,8 +53,8 @@ function observeTask(value, seen) {
53
53
  seen.add("task.browsers");
54
54
  if (some(value.attributes))
55
55
  seen.add("task.attributes");
56
- if (isRecord(value.handlingHistory) && some(value.handlingHistory.steps))
57
- seen.add("task.handlingHistory");
56
+ if (isRecord(value.interactionHistory) && some(value.interactionHistory.steps))
57
+ seen.add("task.interactionHistory");
58
58
  if (some(value.onCall))
59
59
  seen.add("task.onCall");
60
60
  if (value.leadAssist !== undefined)
@@ -305,6 +305,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
305
305
  // What every connection owes on connect, the first and a reloaded second alike.
306
306
  const connectObligations = (on) => {
307
307
  // A refusal is visible on both sides: what the host would not take, the adapter is told, with the rules.
308
+ if (adapter.manifest.timeCheck === true)
309
+ requireMethod(on, "checkTime", "the manifest declares timeCheck");
308
310
  requireMethod(on, "refused", "every connection is told what the host refused");
309
311
  // Dial is declared by presence: the capability object carries a destination policy rather
310
312
  // than an `enabled` flag, so its presence is the declaration.
@@ -479,7 +481,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
479
481
  const snapshot = await connection.snapshot();
480
482
  await snapshotRead(live, snapshot, "snapshot");
481
483
  stream.seed(snapshot);
482
- breaks.seed(snapshot);
484
+ violations.push(...breaks.seed(snapshot));
483
485
  seeded = true;
484
486
  // A snapshot supersedes what it restates and nothing else. Of the events held during the read,
485
487
  // a state-replacing kind is dropped, since the snapshot carries that state and must account for
@@ -659,8 +661,8 @@ function userIdsIn(snapshot) {
659
661
  }
660
662
  for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
661
663
  const t = task;
662
- if (isRecord(t.handlingHistory) && Array.isArray(t.handlingHistory.steps))
663
- for (const step of t.handlingHistory.steps)
664
+ if (isRecord(t.interactionHistory) && Array.isArray(t.interactionHistory.steps))
665
+ for (const step of t.interactionHistory.steps)
664
666
  if (isRecord(step))
665
667
  add(step.by);
666
668
  if (Array.isArray(t.onCall))
@@ -679,7 +681,7 @@ function userIdsIn(snapshot) {
679
681
  return [...ids];
680
682
  }
681
683
  const teamNamesUsers = (team) => isRecord(team) && (some(team.members) || some(team.requests));
682
- const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.handlingHistory) && Array.isArray(task.handlingHistory.steps) && task.handlingHistory.steps.some(step => isRecord(step) && step.by !== undefined)) ||
684
+ const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.interactionHistory) && Array.isArray(task.interactionHistory.steps) && task.interactionHistory.steps.some(step => isRecord(step) && step.by !== undefined)) ||
683
685
  (isRecord(task.leadAssist) && task.leadAssist.leadId !== undefined) ||
684
686
  isRecord(task.assisting) ||
685
687
  isRecord(task.monitoring));
@@ -1005,7 +1007,7 @@ export class TaskStream {
1005
1007
  return;
1006
1008
  const entries = [
1007
1009
  ...(Array.isArray(task.onCall) ? task.onCall : []),
1008
- ...(isRecord(task.handlingHistory) && Array.isArray(task.handlingHistory.steps) ? task.handlingHistory.steps : []),
1010
+ ...(isRecord(task.interactionHistory) && Array.isArray(task.interactionHistory.steps) ? task.interactionHistory.steps : []),
1009
1011
  ];
1010
1012
  for (const entry of entries) {
1011
1013
  if (isRecord(entry) && typeof entry.dialId === "string")
@@ -1050,9 +1052,9 @@ export class TaskStream {
1050
1052
  }
1051
1053
  /** The entries of a task's record, each by step and instant, or undefined where the task carries no record. */
1052
1054
  static record(task) {
1053
- if (!isRecord(task) || !isRecord(task.handlingHistory) || !Array.isArray(task.handlingHistory.steps))
1055
+ if (!isRecord(task) || !isRecord(task.interactionHistory) || !Array.isArray(task.interactionHistory.steps))
1054
1056
  return undefined;
1055
- return new Set(task.handlingHistory.steps.filter(isRecord).map(entry => `${String(entry.step)}@${String(entry.at)}`));
1057
+ return new Set(task.interactionHistory.steps.filter(isRecord).map(entry => `${String(entry.step)}@${String(entry.at)}`));
1056
1058
  }
1057
1059
  /** What a restated record lost of the one read before it: nothing, or the entries by step and instant. */
1058
1060
  static lost(was, now) {
@@ -1073,9 +1075,9 @@ export class TaskStream {
1073
1075
  stages.set(entry.dialId, entry.stage);
1074
1076
  }
1075
1077
  }
1076
- const partyRingingByHost = isRecord(task) && Array.isArray(task.onCall)
1077
- && task.onCall.some(entry => isRecord(entry) && entry.role === "party" && entry.stage === "ringing" && typeof entry.dialId === "string");
1078
- return { partyRingingByHost, channel: String(isRecord(task) ? task.channel : undefined), completionMode: String(isRecord(task) ? task.completionMode : undefined),
1078
+ const partyRingingBeforeWork = isRecord(task) && Array.isArray(task.onCall)
1079
+ && task.onCall.some(entry => isRecord(entry) && entry.role === "party" && entry.stage === "ringing" && (typeof entry.dialId === "string" || (task.phase === "preview" && task.atDeadline === "calls" && entry.dialId === undefined)));
1080
+ return { partyRingingBeforeWork, channel: String(isRecord(task) ? task.channel : undefined), completionMode: String(isRecord(task) ? task.completionMode : undefined),
1079
1081
  wrapAllowance: isRecord(task) && typeof task.wrapAllowance === "number" ? task.wrapAllowance : undefined,
1080
1082
  phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task), allocation: String(isRecord(task) ? task.allocationId : undefined) };
1081
1083
  }
@@ -1090,7 +1092,7 @@ export class TaskStream {
1090
1092
  resync(snapshot, at) {
1091
1093
  const found = [];
1092
1094
  const refuse = (rule, where, message) => found.push({ rule, path: where, message });
1093
- ruleEvaluated("stream.snapshot.capabilitySource", "stream.snapshot.handlingHistory", "stream.snapshot.phase", "stream.snapshot.media");
1095
+ ruleEvaluated("stream.snapshot.capabilitySource", "stream.snapshot.interactionHistory", "stream.snapshot.phase", "stream.snapshot.media");
1094
1096
  if (isRecord(snapshot) && Array.isArray(snapshot.tasks)) {
1095
1097
  snapshot.tasks.forEach((task, index) => {
1096
1098
  if (!isRecord(task) || typeof task.id !== "string")
@@ -1104,7 +1106,7 @@ export class TaskStream {
1104
1106
  // A record once read is not unread: a resync restates it whole, or with more, never with less.
1105
1107
  const lost = TaskStream.lost(was.record, TaskStream.record(task));
1106
1108
  if (lost.length > 0) {
1107
- refuse("stream.snapshot.handlingHistory", `${at}.tasks[${index}].handlingHistory`, `${task.id}'s record lost ${lost.join(", ")} on the snapshot: an entry read by the host stays in the record until the task ends`);
1109
+ refuse("stream.snapshot.interactionHistory", `${at}.tasks[${index}].interactionHistory`, `${task.id}'s record lost ${lost.join(", ")} on the snapshot: an entry read by the host stays in the record until the task ends`);
1108
1110
  }
1109
1111
  const to = String(task.phase);
1110
1112
  const reachable = REACHABLE_PHASES[was.phase];
@@ -1176,7 +1178,7 @@ export class TaskStream {
1176
1178
  break;
1177
1179
  }
1178
1180
  // The rules about a known task are evaluated only once there is one.
1179
- ruleEvaluated("stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.allocation", "stream.taskUpdated.mediaOpen", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage", "stream.taskUpdated.stage.lingering");
1181
+ ruleEvaluated("stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.allocation", "stream.taskUpdated.mediaOpen", "stream.taskUpdated.interactionHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage", "stream.taskUpdated.stage.lingering");
1180
1182
  // Terms once read stay read. A re-read that fails is not a new fact about the task, so the
1181
1183
  // last statement stands and the failure is a diagnostic; undetermined is a place a task
1182
1184
  // starts from, never one it returns to.
@@ -1208,7 +1210,7 @@ export class TaskStream {
1208
1210
  {
1209
1211
  const lost = TaskStream.lost(known.record, TaskStream.record(event.task));
1210
1212
  if (lost.length > 0) {
1211
- refuse("stream.taskUpdated.handlingHistory", `${at}.task.handlingHistory`, `${id}'s record lost ${lost.join(", ")} on the update: an entry read by the host stays in the record until the task ends`);
1213
+ refuse("stream.taskUpdated.interactionHistory", `${at}.task.interactionHistory`, `${id}'s record lost ${lost.join(", ")} on the update: an entry read by the host stays in the record until the task ends`);
1212
1214
  }
1213
1215
  }
1214
1216
  // A task completes after its media ends, never around it: an update that moves a task to
@@ -1268,7 +1270,7 @@ export class TaskStream {
1268
1270
  refuse("stream.taskMedia.channel", `${at}.type`, "only a voice task has media transitions");
1269
1271
  break;
1270
1272
  }
1271
- if (!AT_WORK.has(known.phase) && !known.partyRingingByHost) {
1273
+ if (!AT_WORK.has(known.phase) && !known.partyRingingBeforeWork) {
1272
1274
  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`);
1273
1275
  }
1274
1276
  if (known.media === "started") {
@@ -1290,7 +1292,7 @@ export class TaskStream {
1290
1292
  refuse("stream.taskMedia.channel", `${at}.type`, "only a voice task has media transitions");
1291
1293
  break;
1292
1294
  }
1293
- if (!WORK_BEGUN.has(known.phase)) {
1295
+ if (!WORK_BEGUN.has(known.phase) && !known.partyRingingBeforeWork) {
1294
1296
  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`);
1295
1297
  }
1296
1298
  if (known.media !== "started") {
@@ -1317,13 +1319,13 @@ export class TaskStream {
1317
1319
  refuse("stream.taskEnded.mediaOpen", `${at}.taskId`, `${id} ended with its media still started: the audio ends first, on task-media-ended, whatever the outcome`);
1318
1320
  }
1319
1321
  // Off voice there is no media event, so the update that moves a task to completing is the
1320
- // provider's word that handling ended and the moment the wrap allowance starts. A provider
1322
+ // provider's word that interaction ended and the moment the wrap allowance starts. A provider
1321
1323
  // that completes the task itself with an allowance to run has to have started the clock:
1322
1324
  // completed from in-progress, the allowance it stated was never given.
1323
1325
  ruleEvaluated("stream.taskEnded.unwrapped");
1324
1326
  if (known.channel !== "voice" && known.completionMode === "provider-automatic" && known.wrapAllowance !== undefined && known.wrapAllowance > 0
1325
1327
  && known.phase !== "completing" && isRecord(event.outcome) && event.outcome.type === "completed") {
1326
- refuse("stream.taskEnded.unwrapped", `${at}.outcome`, `${id} was completed from ${known.phase} with a wrap allowance of ${known.wrapAllowance}s under provider-automatic: off voice, completing is the provider's word that handling ended and the allowance's start, and it was never published`);
1328
+ refuse("stream.taskEnded.unwrapped", `${at}.outcome`, `${id} was completed from ${known.phase} with a wrap allowance of ${known.wrapAllowance}s under provider-automatic: off voice, completing is the provider's word that interaction ended and the allowance's start, and it was never published`);
1327
1329
  }
1328
1330
  this.endedAllocations.add(known.allocation);
1329
1331
  this.tasks.delete(id);
@@ -1441,7 +1443,7 @@ async function driveOneCall(drive) {
1441
1443
  // is a place the stream's rules keep working, not one where they all stop.
1442
1444
  await drive.snapshotRead(second, snapshot, "drive.reload.snapshot");
1443
1445
  found.push(...drive.streams.stream.resync(snapshot, "drive.reload.snapshot"));
1444
- drive.streams.breaks.seed(snapshot);
1446
+ found.push(...drive.streams.breaks.seed(snapshot));
1445
1447
  const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
1446
1448
  ? snapshot.tasks.find(task => isRecord(task) && task.id === taskId) : undefined;
1447
1449
  ruleEvaluated("drive.reload.snapshot");
@@ -1454,10 +1456,10 @@ async function driveOneCall(drive) {
1454
1456
  if (carried.allocationId !== allocationOf()) {
1455
1457
  refuse("drive.reload.allocation", "drive.reload.allocation", `a second adapter built from the same login carries ${taskId} as allocation ${String(carried.allocationId)}; the first published ${allocationOf()}, and a life does not change its name on a reload`);
1456
1458
  }
1457
- const history = carried.handlingHistory;
1459
+ const history = carried.interactionHistory;
1458
1460
  const steps = isRecord(history) && Array.isArray(history.steps) ? history.steps : [];
1459
1461
  // A record once read is not unread across a reload either: every entry the first adapter published is here.
1460
- const before = latestTask().handlingHistory;
1462
+ const before = latestTask().interactionHistory;
1461
1463
  const wasRead = isRecord(before) && Array.isArray(before.steps) ? before.steps.filter(isRecord) : [];
1462
1464
  const lost = wasRead.filter(entry => !steps.some(now => isRecord(now) && now.step === entry.step && now.at === entry.at))
1463
1465
  .map(entry => `${String(entry.step)}@${String(entry.at)}`);
@@ -1610,11 +1612,11 @@ async function driveOneCall(drive) {
1610
1612
  if (await updated(t => t.phase === "in-progress" || t.phase === "completing", "the task leaving preview after Call") === undefined)
1611
1613
  return found;
1612
1614
  }
1613
- // The other direction of step 4, wherever the task stands outside the handling phases with the
1614
- // control still declared: a host holds it back (command.phase.handling), and an adapter that
1615
+ // The other direction of step 4, wherever the task stands outside the interaction phases with the
1616
+ // control still declared: a host holds it back (command.phase.interaction), and an adapter that
1615
1617
  // receives it anyway must refuse it -- so the drive sends it past the validator and expects failed.
1616
- const holdRefusedOutsideHandling = async () => {
1617
- ruleEvaluated("drive.command.rejected", "drive.command.handling");
1618
+ const holdRefusedOutsideInteraction = async () => {
1619
+ ruleEvaluated("drive.command.rejected", "drive.command.interaction");
1618
1620
  const phase = String(latestTask().phase);
1619
1621
  let answer;
1620
1622
  try {
@@ -1624,12 +1626,12 @@ async function driveOneCall(drive) {
1624
1626
  refuse("drive.command.rejected", "drive.command.hold", `execute rejected rather than answered: ${String(error)}`);
1625
1627
  }
1626
1628
  if (isRecord(answer) && answer.status !== "failed") {
1627
- refuse("drive.command.handling", "drive.command.hold", `the provider applied hold on a ${phase} task: a control on the contact belongs to in-progress or paused, and the adapter is the second gate`);
1629
+ refuse("drive.command.interaction", "drive.command.hold", `the provider applied hold on a ${phase} task: a control on the contact belongs to in-progress or paused, and the adapter is the second gate`);
1628
1630
  }
1629
1631
  };
1630
1632
  if (latestTask().phase === "confirmed") {
1631
1633
  if (offers("hold"))
1632
- await holdRefusedOutsideHandling();
1634
+ await holdRefusedOutsideInteraction();
1633
1635
  if (await updated(t => t.phase !== "confirmed", "the task leaving confirmed") === undefined)
1634
1636
  return found;
1635
1637
  }
@@ -1655,12 +1657,13 @@ async function driveOneCall(drive) {
1655
1657
  // 3b. The microphone is the host's. With the audio open, the drive mutes it for a moment and
1656
1658
  // reports the leg the provider's record would otherwise miss -- begun, then ended -- and
1657
1659
  // expects each report recorded. If the provider restates the task's record afterwards, the leg is in it.
1660
+ const canonicalTimes = new Map();
1658
1661
  const reportedLegs = [];
1659
1662
  /** The duration the provider's record states for the leg at `at`, as the task stands now. */
1660
1663
  const closedAs = (at) => {
1661
- const history = latestTask().handlingHistory;
1664
+ const history = latestTask().interactionHistory;
1662
1665
  const leg = isRecord(history) && Array.isArray(history.steps)
1663
- ? history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at) : undefined;
1666
+ ? history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === canonicalTimes.get(at)) : undefined;
1664
1667
  return typeof leg?.seconds === "number" ? leg.seconds : undefined;
1665
1668
  };
1666
1669
  const canRecordMute = session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function";
@@ -1668,7 +1671,7 @@ async function driveOneCall(drive) {
1668
1671
  ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
1669
1672
  // The drive holds its own report to the contract before it crosses, as a host must.
1670
1673
  const leg = { taskId, allocationId: allocationOf(), step: "muted", mutedBy: "host", ...body };
1671
- const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
1674
+ const own = validateInteractionReport(leg, "drive.recordStep.report", drive.manifest);
1672
1675
  found.push(...own);
1673
1676
  if (own.length > 0)
1674
1677
  return;
@@ -1680,7 +1683,15 @@ async function driveOneCall(drive) {
1680
1683
  refuse("drive.recordStep.rejected", "drive.recordStep", `recordStep rejected rather than answered: ${String(error)}`);
1681
1684
  return;
1682
1685
  }
1683
- found.push(...validateResult(answer, "recordStep", "drive.recordStep.result"));
1686
+ const resultErrors = validateResult(answer, "recordStep", "drive.recordStep.result");
1687
+ found.push(...resultErrors);
1688
+ if (!resultErrors.length && isRecord(answer) && answer.status === "recorded" && typeof answer.at === "string" && typeof body.at === "string") {
1689
+ const prior = canonicalTimes.get(body.at);
1690
+ if (prior !== undefined && prior !== answer.at)
1691
+ refuse("drive.recordStep.identity", "drive.recordStep.result.at", "one reported leg must retain its provider-assigned history instant");
1692
+ else
1693
+ canonicalTimes.set(body.at, answer.at);
1694
+ }
1684
1695
  if (isRecord(answer) && answer.status === "failed") {
1685
1696
  refuse("drive.recordStep.failed", "drive.recordStep", `the provider refused to record the host's muted leg: ${String(isRecord(answer.failure) ? answer.failure.code : answer.failure)}`);
1686
1697
  }
@@ -1705,12 +1716,12 @@ async function driveOneCall(drive) {
1705
1716
  await new Promise(resolve => setTimeout(resolve, 1000));
1706
1717
  setMuted(false);
1707
1718
  await report({ at, seconds: Math.round((Date.now() - began) / 1000), ended: true });
1708
- reportedLegs.push({ at, task: latestTask() });
1719
+ reportedLegs.push({ at: canonicalTimes.get(at), task: latestTask() });
1709
1720
  // 3c. A host reload destroys the adapter object and keeps the login's store. Built again from
1710
1721
  // the same login, a second adapter carries the task and the leg -- from its platform, or from
1711
1722
  // the store -- or it composed the record in memory and the record died with it.
1712
- if (drive.rebuild !== undefined) {
1713
- await recordSurvivesReload(at);
1723
+ if (drive.rebuild !== undefined && canonicalTimes.has(at)) {
1724
+ await recordSurvivesReload(canonicalTimes.get(at));
1714
1725
  // A reload that stood no client ends the run: there is nothing left to drive.
1715
1726
  if (!drive.handOver.isLive())
1716
1727
  return found;
@@ -1761,10 +1772,10 @@ async function driveOneCall(drive) {
1761
1772
  // record as it stands now is what every later restatement is held to.
1762
1773
  const before = closedAs(openLeg.at);
1763
1774
  await report({ at: openLeg.at, seconds: Math.max(1, Math.round((Date.now() - openLeg.began) / 1000)), ended: true });
1764
- reportedLegs.push({ at: openLeg.at, task: openLeg.task, closedAs: before });
1775
+ reportedLegs.push({ at: canonicalTimes.get(openLeg.at), task: openLeg.task, closedAs: before });
1765
1776
  }
1766
1777
  if (completing !== undefined && offers("hold")) {
1767
- await holdRefusedOutsideHandling();
1778
+ await holdRefusedOutsideInteraction();
1768
1779
  }
1769
1780
  }
1770
1781
  }
@@ -1787,11 +1798,11 @@ async function driveOneCall(drive) {
1787
1798
  command.disposition = dispositions.codes[0].id;
1788
1799
  }
1789
1800
  if (await send(command) !== undefined) {
1790
- // applied says the provider has disposed of the task, and its ending follows within the bound
1801
+ // applied says the provider has completed the task, and its ending follows within the bound
1791
1802
  // the manifest stated. Past it the host resyncs: a snapshot still carrying the task is a task
1792
1803
  // held open by a provider that said it was done, and the desk shows it as unsettled.
1793
- ruleEvaluated("drive.disposal.unsettled");
1794
- const settle = Number(drive.manifest.disposalSettleMs);
1804
+ ruleEvaluated("drive.completion.unsettled");
1805
+ const settle = Number(drive.manifest.completionSettleMs);
1795
1806
  let unsettled = false;
1796
1807
  const end = await ended({ ms: settle, onExpiry: () => { unsettled = true; } });
1797
1808
  if (end !== undefined)
@@ -1802,11 +1813,11 @@ async function driveOneCall(drive) {
1802
1813
  resync = await drive.connection.snapshot();
1803
1814
  }
1804
1815
  catch (error) {
1805
- refuse("drive.command.rejected", "drive.disposal", `snapshot() after an unsettled disposal rejected: ${String(error)}`);
1816
+ refuse("drive.command.rejected", "drive.completion", `snapshot() after an unsettled completion rejected: ${String(error)}`);
1806
1817
  }
1807
1818
  const still = isRecord(resync) && Array.isArray(resync.tasks) && resync.tasks.some(t => isRecord(t) && t.id === taskId && t.allocationId === allocationOf());
1808
- refuse("drive.disposal.unsettled", "drive.disposal", still
1809
- ? `complete was applied and ${settle}ms later the task-ended has not come and a snapshot still carries ${taskId}: applied says the provider disposed of the task, and it has not`
1819
+ refuse("drive.completion.unsettled", "drive.completion", still
1820
+ ? `complete was applied and ${settle}ms later the task-ended has not come and a snapshot still carries ${taskId}: applied says the provider completed the task, and it has not`
1810
1821
  : `complete was applied and ${settle}ms later the task-ended has not come; a snapshot no longer carries ${taskId}, so the ending was owed and never sent`);
1811
1822
  }
1812
1823
  }
@@ -1826,10 +1837,12 @@ async function driveOneCall(drive) {
1826
1837
  return latestTask();
1827
1838
  };
1828
1839
  for (const reported of reportedLegs) {
1840
+ if (reported.at === undefined)
1841
+ continue; // A refused/malformed report provides no accepted history identity.
1829
1842
  const published = lastPublished();
1830
1843
  if (published === reported.task)
1831
1844
  continue;
1832
- const history = published.handlingHistory;
1845
+ const history = published.interactionHistory;
1833
1846
  if (isRecord(history) && Array.isArray(history.steps)) {
1834
1847
  const { at } = reported;
1835
1848
  const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
@@ -1856,62 +1869,56 @@ async function driveOneCall(drive) {
1856
1869
  // starting-after-task | in-effect; work ending goes starting-after-task -> in-effect; a denial,
1857
1870
  // a cancel, an end or a release goes back to not-requested; a placed break arrives in-effect
1858
1871
  // with `imposed`. Nothing else is a move the guide describes.
1859
- const COMMITTED = new Set(["starting-after-task", "in-effect"]);
1860
- const BACKWARDS = {
1861
- "in-effect": ["awaiting-decision", "granted", "starting-after-task"],
1862
- "starting-after-task": ["awaiting-decision", "granted"],
1863
- granted: ["awaiting-decision"],
1864
- };
1865
1872
  /** What a stream has said about the agent's break, and the moves it may not make. */
1866
1873
  export class BreakStream {
1867
- approval;
1868
- /** Takes the break state a snapshot carries as the point the stream continues from. */
1874
+ state;
1875
+ recovery = true;
1876
+ /** True until a validated authoritative snapshot establishes a usable baseline. */
1877
+ get needsRecovery() { return this.recovery; }
1878
+ /** Caller establishes snapshot freshness/login scope separately; malformed state never replaces the baseline. */
1869
1879
  seed(snapshot) {
1870
1880
  const state = isRecord(snapshot) ? snapshot.break : undefined;
1871
- this.approval = isRecord(state) && typeof state.approval === "string" ? state.approval : undefined;
1881
+ const found = validateBreakTransition(state, state, "snapshot.break");
1882
+ if (found.length) {
1883
+ this.recovery = true;
1884
+ return found;
1885
+ }
1886
+ this.state = state;
1887
+ this.recovery = false;
1888
+ return [];
1872
1889
  }
1873
- /** Applies one envelope and returns the moves it may not make given where the break stood. */
1890
+ /** Rejected transitions keep accepted state and require snapshot recovery before further deltas. */
1874
1891
  apply(envelope, path = "event") {
1875
- const found = [];
1876
1892
  const event = isRecord(envelope) ? envelope.event : undefined;
1877
1893
  if (!isRecord(event))
1878
- return found;
1879
- if (event.type === "snapshot") {
1880
- this.seed(event.snapshot);
1881
- return found;
1882
- }
1883
- if (event.type !== "break-state" || !isRecord(event.break) || typeof event.break.approval !== "string")
1884
- return found;
1885
- const from = this.approval;
1886
- const to = event.break.approval;
1887
- const at = `${path}.event.break.approval`;
1888
- if (from !== undefined) {
1889
- // A commit's states need a grant behind them. A placed break is the one arrival in a
1890
- // committed state that nobody asked for -- in effect at once, or starting-after-task while
1891
- // the member finishes a call -- and it says so with `imposed`.
1892
- if (COMMITTED.has(to) && (from === "not-requested" || from === "awaiting-decision") && event.break.imposed === undefined) {
1893
- found.push({ rule: "stream.breakState.commitBeforeGrant", path: at,
1894
- message: `${to} follows a commit, and a commit follows granted; the break stood at ${from}` });
1895
- }
1896
- if ((BACKWARDS[from] ?? []).includes(to)) {
1897
- found.push({ rule: "stream.breakState.backwards", path: at,
1898
- message: `a break does not go from ${from} back to ${to}; a new request passes through not-requested` });
1899
- }
1894
+ return [];
1895
+ if (event.type === "transport-status" && event.status !== "active") {
1896
+ this.recovery = true;
1897
+ return [];
1900
1898
  }
1901
- this.approval = to;
1899
+ if (event.type === "snapshot")
1900
+ return this.seed(event.snapshot);
1901
+ if (event.type !== "break-state")
1902
+ return [];
1903
+ if (this.recovery)
1904
+ return [{ rule: "stream.breakState.baseline", path: `${path}.event.break`,
1905
+ message: "a validated authoritative snapshot is required before accepting break updates" }];
1906
+ const found = validateBreakTransition(this.state, event.break, `${path}.event.break`);
1907
+ if (found.length)
1908
+ this.recovery = true;
1909
+ else
1910
+ this.state = event.break;
1902
1911
  return found;
1903
1912
  }
1904
1913
  }
1905
1914
  /**
1906
- * A break follows its requests. Given a provider's stream -- optionally seeded with the snapshot
1907
- * it began from -- every `break-state` moves the way the guide describes: a commit's states only
1915
+ * A break follows its requests. Given a provider's stream -- seeded with its initial snapshot,
1916
+ * either supplied separately or carried by a snapshot event -- every `break-state` moves the way the guide describes: a commit's states only
1908
1917
  * after a grant, never backwards, and a break placed on the agent arriving in effect with `imposed`.
1909
1918
  */
1910
1919
  export function assertBreakFollowsItsRequests(envelopes, snapshot) {
1911
1920
  const stream = new BreakStream();
1912
- if (snapshot !== undefined)
1913
- stream.seed(snapshot);
1914
- const found = [];
1921
+ const found = snapshot === undefined ? [] : stream.seed(snapshot);
1915
1922
  envelopes.forEach((envelope, index) => found.push(...stream.apply(envelope, `envelopes[${index}]`)));
1916
1923
  assertNoViolations(found, "A break follows its requests");
1917
1924
  }
@@ -22,6 +22,13 @@ export declare function validateScheduledActivity(activity: unknown, path?: stri
22
22
  * on voice, absent elsewhere.
23
23
  */
24
24
  export declare function validatePhone(value: unknown, manifest: unknown, path?: string): ProtocolViolation[];
25
+ /** Undefined explicitly means unavailable; a returned estimate is not a clock guarantee. */
26
+ export declare function validateProviderTimeEstimate(value: unknown, scope: unknown, path?: string): ProtocolViolation[];
27
+ /** Validate host-local periodic clock-check configuration. Omission disables checking. */
28
+ export declare function validateProviderTimeCheckPolicy(value: unknown, path?: string): ProtocolViolation[];
29
+ export declare function validateProviderTimeCheckRequest(value: unknown, path?: string): ProtocolViolation[];
30
+ /** Checks response shape/correlation, not source-clock accuracy, round-trip delay or freshness. */
31
+ export declare function validateProviderTimeCheckResult(value: unknown, request: unknown, loginId: string, path?: string): ProtocolViolation[];
25
32
  export declare function validateManifest(manifest: unknown, path?: string): ProtocolViolation[];
26
33
  export interface TaskValidationContext {
27
34
  /** The provider's channel, from its manifest. A task must agree with it. */
@@ -40,6 +47,30 @@ export interface TaskValidationContext {
40
47
  locked?: readonly string[];
41
48
  }
42
49
  export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
50
+ /** Checks break status against the full currently retained task set, after a transaction. */
51
+ export declare function validateBreakStatus(state: unknown, tasks: unknown, path?: string): ProtocolViolation[];
52
+ /**
53
+ * Checks lead break dispatch using current authentication, transport, roster (`team`) and,
54
+ * for place/release, the target's full `memberBreak`. The provider must authorize the target
55
+ * and recheck the decision atomically; a roster is not authority to act after it has changed.
56
+ */
57
+ export declare function validateTeamBreakCommand(request: unknown, context: unknown, path?: string): ProtocolViolation[];
58
+ /** The four agent break methods; this is a validation API, not a wire command. */
59
+ export type BreakMethod = "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak";
60
+ /**
61
+ * Validate immediately before dispatch against current provider state. Context must contain
62
+ * the current authentication and transport. This cannot fence a concurrent backend change;
63
+ * the provider must recheck atomically. Result validation never replaces authoritative state.
64
+ */
65
+ export declare function validateBreakCommand(method: BreakMethod, request: unknown, state: unknown, context: unknown, path?: string): ProtocolViolation[];
66
+ /**
67
+ * Checks two complete break states within one provider/login's ordered event stream.
68
+ * Call before replacing accepted state; report violations and reconcile on failure.
69
+ * This is not a freshness check: validate a fresh authoritative snapshot separately and
70
+ * use it as a new baseline, never as an event transition. Source ordering and connection
71
+ * fencing remain required; neither timestamps nor approval rank identify a break attempt.
72
+ */
73
+ export declare function validateBreakTransition(before: unknown, after: unknown, path?: string): ProtocolViolation[];
43
74
  /**
44
75
  * Who is reading what the adapter published, and what their login declares. The validators check
45
76
  * structure without it; given it, they also hold what the adapter publishes to the login.
@@ -91,8 +122,10 @@ export declare function validateHostGuarantees(guarantees: unknown, path?: strin
91
122
  * a task, a step, when it began, how long so far if the host says, and an explicit end that
92
123
  * carries the final duration.
93
124
  */
94
- export declare function validateHandlingReport(report: unknown, path?: string, manifest?: unknown): ProtocolViolation[];
125
+ export declare function validateInteractionReport(report: unknown, path?: string, manifest?: unknown): ProtocolViolation[];
95
126
  export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
127
+ /** Validate the exact interaction/allocation target before provider command dispatch. */
128
+ export declare function validateTaskCommandRequest(request: unknown, task: unknown, path?: string, taskContext?: Omit<TaskValidationContext, "channel">): ProtocolViolation[];
96
129
  /**
97
130
  * What a command needs to be issuable, checked against the task it names: the capability the
98
131
  * guide's table gates it on, the phase it belongs to, and the state that has to stand -- a