@xema/omni-protocol 0.1.72 → 0.1.74

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/index.d.ts CHANGED
@@ -174,6 +174,13 @@ export interface Manifest<C extends Channel = Channel> {
174
174
  * when it ended, and a running report is refused: what a provider never asked for never crosses.
175
175
  */
176
176
  runningStepReports?: true;
177
+ /**
178
+ * How long after an applied disposal -- `complete`, `transfer` `complete`, `lead-assist`
179
+ * `take-over` -- the provider's `task-ended` is owed, in milliseconds. `applied` says the provider
180
+ * has disposed of the task; the ending follows within this, or the host resyncs and shows the
181
+ * task as unsettled. Stated per provider, since platforms settle at different speeds.
182
+ */
183
+ disposalSettleMs: number;
177
184
  }
178
185
  export interface SecretStore {
179
186
  get(key: string): Promise<string | undefined>;
package/dist/testing.d.ts CHANGED
@@ -133,6 +133,11 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
133
133
  * both.
134
134
  */
135
135
  export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
136
+ /**
137
+ * The event kinds a snapshot restates, and so supersedes when delivered while it is read. Every
138
+ * other kind reports a transaction no snapshot carries, and a host applies it after the snapshot.
139
+ */
140
+ export declare const SUPERSEDED_BY_A_SNAPSHOT: ReadonlySet<string>;
136
141
  export declare class TaskStream {
137
142
  private readonly tasks;
138
143
  private readonly allocations;
package/dist/testing.js CHANGED
@@ -162,6 +162,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
162
162
  const stream = new TaskStream();
163
163
  const breaks = new BreakStream();
164
164
  let seeded = false;
165
+ const duringRead = [];
165
166
  const storedSecrets = new Map();
166
167
  const authenticationSecrets = {
167
168
  get: async (key) => storedSecrets.get(key),
@@ -369,9 +370,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
369
370
  }
370
371
  eventPayloads.set(envelope.id, payload);
371
372
  }
372
- // Cross-event rules apply once the stream has a beginning: the connect snapshot.
373
+ // Cross-event rules apply once the stream has a beginning: the connect snapshot. An event
374
+ // delivered while that snapshot is read is held until it lands, and read then against it.
373
375
  if (seeded)
374
376
  violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
377
+ else
378
+ duringRead.push(envelope);
375
379
  events.push(envelope);
376
380
  for (const waiter of waiters)
377
381
  waiter(envelope);
@@ -426,6 +430,32 @@ export async function exerciseAdapter(adapter, context, options = {}) {
426
430
  stream.seed(snapshot);
427
431
  breaks.seed(snapshot);
428
432
  seeded = true;
433
+ // A snapshot supersedes what it restates and nothing else. Of the events held during the read,
434
+ // a state-replacing kind is dropped, since the snapshot carries that state and must account for
435
+ // it; the rest -- a dial's outcome, a diagnostic, an announcement, a queue summary -- report
436
+ // transactions no snapshot carries, and are applied after it, in order.
437
+ ruleEvaluated("snapshot.accounts.task", "snapshot.accounts.ended");
438
+ const carried = new Set();
439
+ for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
440
+ if (isRecord(task) && typeof task.allocationId === "string")
441
+ carried.add(task.allocationId);
442
+ }
443
+ for (const held of duringRead) {
444
+ const event = held.event;
445
+ if (!SUPERSEDED_BY_A_SNAPSHOT.has(String(event.type))) {
446
+ violations.push(...stream.apply(held), ...breaks.apply(held));
447
+ continue;
448
+ }
449
+ if ((event.type === "task-offered" || event.type === "task-updated") && isRecord(event.task) && typeof event.task.allocationId === "string" && !carried.has(event.task.allocationId)) {
450
+ violations.push({ rule: "snapshot.accounts.task", path: "snapshot.tasks",
451
+ message: `${String(event.task.id)} (${event.task.allocationId}) was published while the snapshot was read and the snapshot does not carry it: a snapshot accounts for everything the adapter emitted before it resolved` });
452
+ }
453
+ if (event.type === "task-ended" && typeof event.allocationId === "string" && carried.has(event.allocationId)) {
454
+ violations.push({ rule: "snapshot.accounts.ended", path: "snapshot.tasks",
455
+ message: `${String(event.taskId)} (${event.allocationId}) ended while the snapshot was read and the snapshot still carries it: a snapshot accounts for everything the adapter emitted before it resolved` });
456
+ }
457
+ }
458
+ duringRead.length = 0;
429
459
  requireCapabilityMethods(live, current().capabilities);
430
460
  if (publishesUserIds(snapshot)) {
431
461
  requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
@@ -905,6 +935,14 @@ const REACHABLE_PHASES = {
905
935
  paused: new Set(["paused", "in-progress", "completing"]),
906
936
  completing: new Set(["completing"]),
907
937
  };
938
+ /**
939
+ * The event kinds a snapshot restates, and so supersedes when delivered while it is read. Every
940
+ * other kind reports a transaction no snapshot carries, and a host applies it after the snapshot.
941
+ */
942
+ export const SUPERSEDED_BY_A_SNAPSHOT = new Set([
943
+ "snapshot", "transport-status", "break-state", "task-offered", "task-updated", "task-ended",
944
+ "task-media-started", "task-media-ended", "team-updated", "contacts-updated", "calendar-updated",
945
+ ]);
908
946
  export class TaskStream {
909
947
  tasks = new Map();
910
948
  // Every allocation this login has seen, and the ones whose task has ended: an offer never reuses
@@ -1217,7 +1255,7 @@ async function driveOneCall(drive) {
1217
1255
  const refuse = (rule, path, message) => found.push({ rule, path, message });
1218
1256
  const isTask = (value) => isRecord(value) && typeof value.id === "string";
1219
1257
  // Wait for an envelope that satisfies `matches`, looking first at what already arrived.
1220
- const waitFor = (what, matches, from) => new Promise(resolve => {
1258
+ const waitFor = (what, matches, from, within = { ms: drive.timeoutMs, onExpiry: () => refuse("drive.timeout", "drive", `the provider owed ${what} within ${drive.timeoutMs}ms and it never arrived`) }) => new Promise(resolve => {
1221
1259
  for (let index = from; index < drive.events.length; index += 1) {
1222
1260
  const hit = matches(drive.events[index]);
1223
1261
  if (hit !== undefined) {
@@ -1227,9 +1265,9 @@ async function driveOneCall(drive) {
1227
1265
  }
1228
1266
  const timer = setTimeout(() => {
1229
1267
  drive.waiters.delete(waiter);
1230
- refuse("drive.timeout", "drive", `the provider owed ${what} within ${drive.timeoutMs}ms and it never arrived`);
1268
+ within.onExpiry();
1231
1269
  resolve(undefined);
1232
- }, drive.timeoutMs);
1270
+ }, within.ms);
1233
1271
  const waiter = (envelope) => {
1234
1272
  const hit = matches(envelope);
1235
1273
  if (hit === undefined)
@@ -1368,10 +1406,10 @@ async function driveOneCall(drive) {
1368
1406
  return undefined;
1369
1407
  }, cursor).then(hit => { if (hit)
1370
1408
  cursor = hit.at; return hit; });
1371
- const ended = () => waitFor("a task-ended for the driven task", envelope => {
1409
+ const ended = (within) => waitFor("a task-ended for the driven task", envelope => {
1372
1410
  const event = envelope.event;
1373
1411
  return event.type === "task-ended" && event.taskId === taskId ? event : undefined;
1374
- }, cursor);
1412
+ }, cursor, within);
1375
1413
  // Every command the drive sends is validated against the task as published, and its answer for its method.
1376
1414
  ruleEvaluated("drive.timeout");
1377
1415
  const send = async (command, dialId) => {
@@ -1465,46 +1503,47 @@ async function driveOneCall(drive) {
1465
1503
  // 3b. The microphone is the host's. With the audio open, the drive mutes it for a moment and
1466
1504
  // reports the leg the provider's record would otherwise miss -- begun, then ended -- and
1467
1505
  // expects each report recorded. If the provider restates the task's record afterwards, the leg is in it.
1468
- let mutedLeg;
1469
- if (session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function") {
1506
+ const reportedLegs = [];
1507
+ const canRecordMute = session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function";
1508
+ const report = async (body) => {
1509
+ ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
1510
+ // The drive holds its own report to the contract before it crosses, as a host must.
1511
+ const leg = { taskId, allocationId: allocationOf(), step: "muted", mutedBy: "host", ...body };
1512
+ const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
1513
+ found.push(...own);
1514
+ if (own.length > 0)
1515
+ return;
1516
+ let answer;
1517
+ try {
1518
+ answer = await drive.connection.recordStep(leg);
1519
+ }
1520
+ catch (error) {
1521
+ refuse("drive.recordStep.rejected", "drive.recordStep", `recordStep rejected rather than answered: ${String(error)}`);
1522
+ return;
1523
+ }
1524
+ found.push(...validateResult(answer, "recordStep", "drive.recordStep.result"));
1525
+ if (isRecord(answer) && answer.status === "failed") {
1526
+ 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)}`);
1527
+ }
1528
+ };
1529
+ const setMuted = (muted) => {
1530
+ try {
1531
+ session.setMuted(muted);
1532
+ }
1533
+ catch {
1534
+ refuse("drive.openMedia.setMuted", "drive.openMedia", `the media session threw on setMuted(${String(muted)})`);
1535
+ }
1536
+ };
1537
+ if (canRecordMute) {
1470
1538
  const at = new Date().toISOString();
1471
- const report = async (body) => {
1472
- ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
1473
- // The drive holds its own report to the contract before it crosses, as a host must.
1474
- const leg = { taskId, allocationId: allocationOf(), step: "muted", at, mutedBy: "host", ...body };
1475
- const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
1476
- found.push(...own);
1477
- if (own.length > 0)
1478
- return;
1479
- let answer;
1480
- try {
1481
- answer = await drive.connection.recordStep(leg);
1482
- }
1483
- catch (error) {
1484
- refuse("drive.recordStep.rejected", "drive.recordStep", `recordStep rejected rather than answered: ${String(error)}`);
1485
- return;
1486
- }
1487
- found.push(...validateResult(answer, "recordStep", "drive.recordStep.result"));
1488
- if (isRecord(answer) && answer.status === "failed") {
1489
- 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)}`);
1490
- }
1491
- };
1492
- const setMuted = (muted) => {
1493
- try {
1494
- session.setMuted(muted);
1495
- }
1496
- catch {
1497
- refuse("drive.openMedia.setMuted", "drive.openMedia", `the media session threw on setMuted(${String(muted)})`);
1498
- }
1499
- };
1500
1539
  const began = Date.now();
1501
1540
  setMuted(true);
1502
- await report({});
1541
+ await report({ at });
1503
1542
  // A leg's duration is whole seconds and a leg shorter than one cannot be stated, so the mute holds for one.
1504
1543
  await new Promise(resolve => setTimeout(resolve, 1000));
1505
1544
  setMuted(false);
1506
- await report({ seconds: Math.round((Date.now() - began) / 1000), ended: true });
1507
- mutedLeg = { at, task: latestTask() };
1545
+ await report({ at, seconds: Math.round((Date.now() - began) / 1000), ended: true });
1546
+ reportedLegs.push({ at, task: latestTask() });
1508
1547
  // 3c. A host reload destroys the adapter object and keeps the login's store. Built again from
1509
1548
  // the same login, a second adapter carries the task and the leg -- from its platform, or from
1510
1549
  // the store -- or it composed the record in memory and the record died with it.
@@ -1525,8 +1564,18 @@ async function driveOneCall(drive) {
1525
1564
  }
1526
1565
  }
1527
1566
  }
1528
- // 5. End the call, where the agent may.
1567
+ // 5. End the call, where the agent may -- with the microphone muted, as agents do. The leg left
1568
+ // open at media end is the provider's to close, since the provider knows the instant the media
1569
+ // ended; the host's own closing report can only follow what it hears, is answered recorded, and
1570
+ // changes nothing. So the drive mutes, does not report the end before end-call, and reports it
1571
+ // after the media has ended.
1529
1572
  if (drive.channel === "voice" && latestTask().phase === "in-progress" && offers("endCall")) {
1573
+ let openLeg;
1574
+ if (canRecordMute) {
1575
+ openLeg = { at: new Date().toISOString(), began: Date.now() };
1576
+ setMuted(true);
1577
+ await report({ at: openLeg.at });
1578
+ }
1530
1579
  if (await send({ type: "end-call" }) !== undefined) {
1531
1580
  // A task that completes with its audio still up is refused by the stream as it passes
1532
1581
  // (stream.taskUpdated.mediaOpen); the drive does not also wait out the clock for an ending
@@ -1541,6 +1590,12 @@ async function driveOneCall(drive) {
1541
1590
  }, cursor);
1542
1591
  if (mediaEnded?.found === "ended")
1543
1592
  cursor = mediaEnded.at;
1593
+ if (openLeg !== undefined) {
1594
+ setMuted(false);
1595
+ // Stated at the record's grain: a leg that rounds to nought is 1.
1596
+ await report({ at: openLeg.at, seconds: Math.max(1, Math.round((Date.now() - openLeg.began) / 1000)), ended: true });
1597
+ reportedLegs.push({ at: openLeg.at, task: latestTask() });
1598
+ }
1544
1599
  if (await updated(t => t.phase === "completing", "the task completing after its media ended") !== undefined && offers("hold")) {
1545
1600
  await holdRefusedOutsideHandling();
1546
1601
  }
@@ -1564,18 +1619,42 @@ async function driveOneCall(drive) {
1564
1619
  if (isRecord(dispositions) && dispositions.required === true && Array.isArray(dispositions.codes) && isRecord(dispositions.codes[0])) {
1565
1620
  command.disposition = dispositions.codes[0].id;
1566
1621
  }
1567
- if (await send(command) !== undefined && await ended() !== undefined)
1568
- await storeReleased();
1622
+ if (await send(command) !== undefined) {
1623
+ // applied says the provider has disposed of the task, and its ending follows within the bound
1624
+ // the manifest stated. Past it the host resyncs: a snapshot still carrying the task is a task
1625
+ // held open by a provider that said it was done, and the desk shows it as unsettled.
1626
+ ruleEvaluated("drive.disposal.unsettled");
1627
+ const settle = Number(drive.manifest.disposalSettleMs);
1628
+ let unsettled = false;
1629
+ const end = await ended({ ms: settle, onExpiry: () => { unsettled = true; } });
1630
+ if (end !== undefined)
1631
+ await storeReleased();
1632
+ else if (unsettled) {
1633
+ let resync;
1634
+ try {
1635
+ resync = await drive.connection.snapshot();
1636
+ }
1637
+ catch (error) {
1638
+ refuse("drive.command.rejected", "drive.disposal", `snapshot() after an unsettled disposal rejected: ${String(error)}`);
1639
+ }
1640
+ const still = isRecord(resync) && Array.isArray(resync.tasks) && resync.tasks.some(t => isRecord(t) && t.id === taskId && t.allocationId === allocationOf());
1641
+ refuse("drive.disposal.unsettled", "drive.disposal", still
1642
+ ? `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`
1643
+ : `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`);
1644
+ }
1645
+ }
1569
1646
  }
1570
1647
  else if (latestTask().phase === "completing") {
1571
1648
  if (await ended() !== undefined)
1572
1649
  await storeReleased();
1573
1650
  }
1574
- // The record kept by the provider has the leg the host reported, wherever the provider restated it.
1575
- if (mutedLeg !== undefined && latestTask() !== mutedLeg.task) {
1651
+ // The record kept by the provider has every leg the host reported, wherever the provider restated it.
1652
+ for (const reported of reportedLegs) {
1653
+ if (latestTask() === reported.task)
1654
+ continue;
1576
1655
  const history = latestTask().handlingHistory;
1577
1656
  if (isRecord(history) && Array.isArray(history.steps)) {
1578
- const { at } = mutedLeg;
1657
+ const { at } = reported;
1579
1658
  const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
1580
1659
  if (leg === undefined) {
1581
1660
  refuse("drive.recordStep.history", "drive.recordStep", `the provider restated the task's record after the host reported a muted leg at ${at}, and the leg is not in it`);
@@ -422,6 +422,7 @@ export function validateManifest(manifest, path = "manifest") {
422
422
  if (manifest.runningStepReports !== undefined) {
423
423
  into.require(manifest.runningStepReports === true, "manifest.runningStepReports", `${path}.runningStepReports`, "runningStepReports is declared by presence, as true; a provider that takes begin and end only omits it");
424
424
  }
425
+ into.require(typeof manifest.disposalSettleMs === "number" && Number.isInteger(manifest.disposalSettleMs) && manifest.disposalSettleMs > 0, "manifest.disposalSettleMs", `${path}.disposalSettleMs`, "disposalSettleMs is how long after an applied disposal the task-ended is owed, a positive whole number of milliseconds, stated by every provider");
425
426
  if (manifest.orgLevels !== undefined) {
426
427
  if (!Array.isArray(manifest.orgLevels)) {
427
428
  into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
@@ -778,7 +779,7 @@ function validateHandlingHistory(value, path, into, task = {}) {
778
779
  into.require(task.phase === "paused", "task.handlingHistory.held.open", `${at}.seconds`, `a held entry without seconds is a hold still running, and the task is ${String(task.phase)}: the hold has ended, and its duration is stated`);
779
780
  }
780
781
  if (entry.step === "muted") {
781
- 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 host ends its legs when the media ends, and the duration is stated");
782
+ 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");
782
783
  }
783
784
  }
784
785
  // A muted entry carries whose the silence was, as the host reported it; no other step has it.
package/guide.md CHANGED
@@ -193,6 +193,7 @@ type Manifest<C extends Channel = Channel> = {
193
193
  dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
194
194
  phones?: C extends "voice" ? Phone[] : never;
195
195
  runningStepReports?: true;
196
+ disposalSettleMs: number;
196
197
  };
197
198
  ```
198
199
 
@@ -1471,6 +1472,7 @@ export default defineAdapter({
1471
1472
  channel: "voice",
1472
1473
  supportedProtocolVersions: [OMNI_PROTOCOL_VERSION],
1473
1474
  authenticationMethods: ["browser-sso"],
1475
+ disposalSettleMs: 5000,
1474
1476
  idleCapabilities: {
1475
1477
  dial: { destinations: "any-number" },
1476
1478
  },
@@ -1506,6 +1508,7 @@ compile time.
1506
1508
  | `phones` | Voice only, and required there: the phones this platform can put an agent on, `softphone` (the call's audio lands in the host) and/or `deskPhone` (a handset the platform rings; the host shows the call and opens nothing). The host picks one per login. See **How the agent hears the call**. |
1507
1509
  | `dialOutcomes` | Voice only. How a dial can end on this platform, as it distinguishes them: `answered` and at least one way of not reaching the destination. Required of a provider that dials at all — an idle dialpad, or tasks that transfer, conference or call back — and a `dial-outcome` carries only a declared member. See **Every dial has an outcome**. |
1508
1510
  | `runningStepReports` | The provider takes running reports of a host-performed step — `recordStep` with `seconds` so far and no `ended`. Omitted, the host sends exactly two reports per leg, when it began and when it ended, and a running one is refused. See **The host records what it performs**. |
1511
+ | `disposalSettleMs` | Required. How long after an applied disposal -- `complete`, `transfer` `complete`, `lead-assist` `take-over` -- the provider's `task-ended` is owed, a positive whole number of milliseconds (`manifest.disposalSettleMs`). Stated per provider, since platforms settle at different speeds. See **`task-ended`**. |
1509
1512
 
1510
1513
  ### Authentication methods
1511
1514
 
@@ -2061,9 +2064,15 @@ report that: `granted` is a promise to honour a later commit.
2061
2064
 
2062
2065
  Returns the provider's complete authoritative state at one point in time.
2063
2066
 
2064
- - Omni registers `subscribe()` before awaiting the initial snapshot and discards anything delivered
2065
- while the snapshot is read, because the snapshot accounts for it. Events after it are applied in
2066
- order.
2067
+ - Omni registers `subscribe()` before awaiting the initial snapshot. An event delivered while the
2068
+ snapshot is read is held until it lands, and what happens to it then depends on what it is. A
2069
+ snapshot restates state, so an event of a state-replacing kind -- `task-offered`, `task-updated`,
2070
+ `task-ended`, `task-media-started`, `task-media-ended`, `break-state`, `team-updated`,
2071
+ `contacts-updated`, `calendar-updated`, `transport-status`, `snapshot` -- is dropped, because the
2072
+ snapshot accounts for it. An event that reports a transaction no snapshot carries --
2073
+ `dial-outcome`, `diagnostic`, `announcement`, `queue-summary` -- is applied after the snapshot,
2074
+ in order: a dial's outcome delivered during a resync still ends the dial, and **Every dial has an
2075
+ outcome** holds through a snapshot. Events after it are applied in order.
2067
2076
  - `tasks` must contain every task currently owned by this agent for this provider.
2068
2077
  - A snapshot replaces Omni's state for this provider; it is not a partial patch.
2069
2078
  - The adapter may return synchronously when it already holds current live values, or
@@ -2537,7 +2546,13 @@ only while its media is up, so a `held` entry without `seconds` on a task that i
2537
2546
  (`task.handlingHistory.held.open`, `.muted.open`). Whoever performs the leg closes it — the
2538
2547
  provider whose platform parks the caller, the host whose microphone it is — by restating the entry
2539
2548
  with its duration, and **an entry that cannot be closed is not written**: open for ever is a
2540
- plausible nought one level down from a total. There is no resumed step; a resume is the end of a
2549
+ plausible nought one level down from a total. **A leg still open when the media ends is closed by
2550
+ the provider**, at that instant, in the same publication that says the media ended: the provider
2551
+ is the one that knows the instant, and the host can only learn of it afterwards. Agents end calls
2552
+ muted, so the host's leg is routinely open at media end; the provider closes it with the duration
2553
+ from the leg's `at` to the media's end, and the host's own closing report for that leg, which
2554
+ follows what it hears, is answered `recorded` and changes nothing, the entry standing as the
2555
+ provider closed it. One publisher of the closed leg, and no order between them to get right. There is no resumed step; a resume is the end of a
2541
2556
  hold, at the entry's `at` plus its `seconds`, and nothing is lost by not naming it twice. The same goes for every step: two mutes are two
2542
2557
  `muted` entries, and a call that joined two queues -- a menu's, then this one -- has two `queued`
2543
2558
  entries, one per join. Order is enforced: an entry earlier than the one before it is refused
@@ -4344,9 +4359,13 @@ a backend that does not know the session yet — yields no snapshot, and the ada
4344
4359
  `connecting` holding what it holds. See **Snapshots establish state; events report transactions**.
4345
4360
 
4346
4361
  A snapshot must account for **everything the adapter has emitted before it resolves**, not merely
4347
- everything emitted when it was requested. Omni discards events buffered during the read on that
4348
- promise; an adapter that serves a stale snapshot and then lets an earlier event through will have
4349
- Omni apply state the snapshot already superseded.
4362
+ everything emitted when it was requested. Omni drops the state-replacing events held during the
4363
+ read on that promise, and applies the rest after it (see **`Connection.snapshot()`**); an adapter
4364
+ that serves a stale snapshot and then lets an earlier event through will have Omni apply state the
4365
+ snapshot already superseded. The harness reads its connect snapshot the same way, and holds the
4366
+ snapshot to accounting for every task event it superseded: a task published during the read and
4367
+ missing from the snapshot, or ended during the read and still carried, is named
4368
+ (`snapshot.accounts.task`, `snapshot.accounts.ended`).
4350
4369
 
4351
4370
  #### Liveness
4352
4371
 
@@ -4492,8 +4511,16 @@ Every outcome ends the task for this agent. On `task-ended`, Omni:
4492
4511
  A `left` outcome ends the task for this agent alone: the call continues without them, as it does
4493
4512
  when a lead who joined it leaves -- see **Lead assist**.
4494
4513
 
4495
- A successful `complete` or `transfer` command does not clear the task. Omni waits for `task-ended`.
4496
- The `task-media-ended` event and the `completing` phase are likewise non-terminal. A replacement
4514
+ A successful `complete` or `transfer` command does not clear the task. Omni waits for `task-ended`,
4515
+ and not for ever: `applied` to a disposal -- `complete`, `transfer` `complete`, `lead-assist`
4516
+ `take-over` -- says the provider has disposed of the task, and its `task-ended` follows within the
4517
+ manifest's `disposalSettleMs`. A provider never answers `applied` for a disposal it has not yet
4518
+ performed. Past the bound the host calls `snapshot()`: a snapshot still carrying the task is a task
4519
+ held open by a provider that said it was done, and the desk shows it as unsettled -- "Completing...
4520
+ the provider has not confirmed" -- naming the command; a snapshot no longer carrying it clears the
4521
+ task, since the ending was owed and lost. The drive holds a provider to the same bound
4522
+ (`drive.disposal.unsettled`). The `task-media-ended` event and the `completing` phase are likewise
4523
+ non-terminal. A replacement
4497
4524
  snapshot that no longer contains the task also clears it. Repeated `task-ended` delivery with the
4498
4525
  same envelope ID is harmless, and a `task-ended` naming an allocation that has already ended is
4499
4526
  recognised as the late event it is, never applied to the life now open under the same id.
@@ -4714,8 +4741,11 @@ as each case was considered, so a test that needs a rule to have run asserts it
4714
4741
  inferring it from an empty `violations`, and a rule absent from it was never looked at, which is a
4715
4742
  gap and not a pass. With the audio open on a softphone,
4716
4743
  the drive mutes it for one second and reports the leg through `recordStep`, begun and then ended,
4717
- expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); and where the provider
4718
- restates the task's record afterwards, the leg is in it or the hole is named
4744
+ expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); then it mutes again and
4745
+ ends the call muted, as agents do, so the leg is open when the media ends, the provider closes it
4746
+ in the completing publication or the open entry is refused (`task.handlingHistory.muted.open`),
4747
+ and the drive's closing report after the media ended is expected `recorded` and to change nothing.
4748
+ Where the provider restates the task's record afterwards, each leg is in it or the hole is named
4719
4749
  (`drive.recordStep.history`). Given `rebuild`, a way to build the adapter again as a host reload
4720
4750
  does, the drive reloads the host as a reload happens: the first client is unsubscribed,
4721
4751
  disconnected and its session closed (`drive.reload.handover`), and only then is a second adapter
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.72",
3
+ "version": "0.1.74",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",