@xema/omni-protocol 0.1.77 → 0.1.79

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/testing.js CHANGED
@@ -240,7 +240,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
240
240
  if (some(capabilities.preferences))
241
241
  requireMethod(on, "setPreference", "the login declares capabilities.preferences");
242
242
  };
243
- unsubscribeAuthentication = authentication.subscribe(state => {
243
+ const onAuthenticationState = (state) => {
244
244
  const own = validateAuthenticationState(state, "authentication", { levels });
245
245
  violations.push(...own);
246
246
  if (own.length > 0)
@@ -254,7 +254,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
254
254
  if (connection !== undefined)
255
255
  requireCapabilityMethods(connection, state.capabilities);
256
256
  }
257
- });
257
+ };
258
+ unsubscribeAuthentication = authentication.subscribe(onAuthenticationState);
258
259
  // The host's report is Omni's output, and a test that hands the adapter a malformed one is
259
260
  // testing a host that cannot exist. Its first report and every later one are validated, a
260
261
  // voice connection's host reports its audio and no other does, and the host the adapter
@@ -282,23 +283,40 @@ export async function exerciseAdapter(adapter, context, options = {}) {
282
283
  const storeShape = validateLoginStore(context.store, "context.store");
283
284
  violations.push(...storeShape);
284
285
  // The harness watches the store it hands over, so what an adapter leaves behind is seen rather than asked for.
285
- const watched = storeShape.length === 0 ? watchStore(context.store) : { store: context.store, held: new Set() };
286
+ const watched = storeShape.length === 0 ? watchStore(context.store) : { store: context.store, held: new Set(), everHeld: new Set() };
286
287
  unsubscribeHost = context.host.subscribe(report => {
287
288
  violations.push(...validateHostReport(report, "context.host"));
288
289
  });
289
- let consulted = false;
290
+ const consulted = { report: false, subscribe: false };
290
291
  // The wrapped host carries the mute kind the test's host stated, so the arms of ConnectContext still hold.
291
292
  const host = {
292
293
  guarantees: context.host.guarantees,
293
294
  ...(context.host.mute === undefined ? {} : { mute: context.host.mute }),
294
- report: () => { consulted = true; return context.host.report(); },
295
- subscribe: listener => { consulted = true; return context.host.subscribe(listener); },
295
+ report: () => { consulted.report = true; return context.host.report(); },
296
+ subscribe: listener => { consulted.subscribe = true; return context.host.subscribe(listener); },
296
297
  };
297
298
  const connected = { ...context, host, store: watched.store };
298
299
  connection = await adapter.connect(connected);
299
- const live = connection;
300
- // A refusal is visible on both sides: what the host would not take, the adapter is told, with the rules.
301
- requireMethod(live, "refused", "every connection is told what the host refused");
300
+ // The standing client: the first connection, then the second after a hand-over. Everything the
301
+ // harness asks of a connection it asks of the one that stands.
302
+ let live = connection;
303
+ // What every connection owes on connect, the first and a reloaded second alike.
304
+ const connectObligations = (on) => {
305
+ // A refusal is visible on both sides: what the host would not take, the adapter is told, with the rules.
306
+ requireMethod(on, "refused", "every connection is told what the host refused");
307
+ // Dial is declared by presence: the capability object carries a destination policy rather
308
+ // than an `enabled` flag, so its presence is the declaration.
309
+ if (adapter.manifest.idleCapabilities?.dial !== undefined)
310
+ requireMethod(on, "dial", "the manifest declares dial");
311
+ // On a softphone the call's audio lands in Omni, so the adapter has to open it; on a desk phone the host opens nothing.
312
+ if (softphone)
313
+ requireMethod(on, "openMedia", "the login is on a softphone");
314
+ // The microphone is the host's, so on a softphone every call can be muted by it, and the
315
+ // record of that leg is the provider's to take; on a desk phone the host holds no microphone.
316
+ if (softphone)
317
+ requireMethod(on, "recordStep", "the login is on a softphone, whose microphone the host mutes");
318
+ };
319
+ connectObligations(live);
302
320
  const tellRefused = (report) => {
303
321
  if (typeof live.refused !== "function")
304
322
  return;
@@ -310,17 +328,6 @@ export async function exerciseAdapter(adapter, context, options = {}) {
310
328
  violations.push({ rule: "connection.refused.rejected", path: "connection.refused", message: `told of a refusal, the adapter threw: ${String(error)}` });
311
329
  }
312
330
  };
313
- // Dial is declared by presence: the capability object carries a destination policy rather
314
- // than an `enabled` flag, so its presence is the declaration.
315
- if (adapter.manifest.idleCapabilities?.dial !== undefined)
316
- requireMethod(live, "dial", "the manifest declares dial");
317
- // On a softphone the call's audio lands in Omni, so the adapter has to open it; on a desk phone the host opens nothing.
318
- if (softphone)
319
- requireMethod(live, "openMedia", "the login is on a softphone");
320
- // The microphone is the host's, so on a softphone every call can be muted by it, and the
321
- // record of that leg is the provider's to take; on a desk phone the host holds no microphone.
322
- if (softphone)
323
- requireMethod(live, "recordStep", "the login is on a softphone, whose microphone the host mutes");
324
331
  const eventPayloads = new Map();
325
332
  let capacityStated;
326
333
  // The drive waits on events: each waiter is offered every envelope as it lands.
@@ -414,23 +421,61 @@ export async function exerciseAdapter(adapter, context, options = {}) {
414
421
  clientLive = false;
415
422
  return clean;
416
423
  },
417
- up: (session, second) => {
424
+ up: async (session, second) => {
418
425
  authentication = session;
419
426
  connection = second;
427
+ live = second;
428
+ // The second client is held to everything the first was on connect: its methods, its login's
429
+ // methods, the login it publishes from here, and a capacity stated to it, since the first's
430
+ // statement died with the first and nothing may be allocated against a count nobody stated to it.
431
+ connectObligations(second);
432
+ requireCapabilityMethods(second, current().capabilities);
433
+ try {
434
+ unsubscribeAuthentication?.();
435
+ }
436
+ catch { /* the closed first session's subscription */ }
437
+ unsubscribeAuthentication = session.subscribe(onAuthenticationState);
420
438
  unsubscribe = second.subscribe(onEnvelope);
421
439
  clientLive = true;
440
+ const restated = await second.setCapacity({ count: capacityStated ?? 1 });
441
+ violations.push(...validateResult(restated, "setCapacity", "drive.reload.setCapacity"));
422
442
  },
423
443
  };
424
- const snapshot = await connection.snapshot();
425
- observeSnapshot(snapshot, seen);
426
- {
427
- const broken = validateSnapshot(snapshot, adapter.manifest, "snapshot", reader());
444
+ // Every snapshot the run reads, the connect snapshot and a reloaded client's, is read the same
445
+ // way: observed, validated and refused back, its terms and locks noted, and every user it names
446
+ // looked up and the answer held to the shape.
447
+ const snapshotRead = async (source, read, path) => {
448
+ observeSnapshot(read, seen);
449
+ const broken = validateSnapshot(read, adapter.manifest, path, reader());
428
450
  violations.push(...broken);
429
451
  if (broken.length > 0)
430
452
  tellRefused({ artefact: "snapshot", violations: broken });
431
- }
432
- violations.push(...undeterminedTasks(Array.isArray(snapshot?.tasks) ? snapshot.tasks : [], "snapshot.tasks"));
433
- lockedPartySeen ||= (Array.isArray(snapshot?.tasks) ? snapshot.tasks : []).some(locksParty);
453
+ const tasks = isRecord(read) && Array.isArray(read.tasks) ? read.tasks : [];
454
+ violations.push(...undeterminedTasks(tasks, `${path}.tasks`));
455
+ lockedPartySeen ||= tasks.some(locksParty);
456
+ if (publishesUserIds(read)) {
457
+ requireMethod(source, "describeUsers", "the snapshot publishes a UserId");
458
+ // Required by presence is not enough: the names the snapshot published are looked up, and what
459
+ // comes back is held to the shape -- an empty answer to a roster of colleagues is named.
460
+ const named = userIdsIn(read);
461
+ if (typeof source.describeUsers === "function" && named.length > 0) {
462
+ let described;
463
+ try {
464
+ described = await source.describeUsers(named);
465
+ violations.push(...validateDescribedUsers(described, named, "connection.describeUsers"));
466
+ if (Array.isArray(described) && described.length === 0) {
467
+ violations.push({ rule: "connection.describeUsers.empty", path: "connection.describeUsers",
468
+ message: `asked about ${named.join(", ")}, whom the snapshot itself named, the provider described nobody` });
469
+ }
470
+ }
471
+ catch (error) {
472
+ violations.push({ rule: "connection.describeUsers.rejected", path: "connection.describeUsers", message: `describeUsers rejected rather than answered: ${String(error)}` });
473
+ }
474
+ }
475
+ }
476
+ };
477
+ const snapshot = await connection.snapshot();
478
+ await snapshotRead(live, snapshot, "snapshot");
434
479
  stream.seed(snapshot);
435
480
  breaks.seed(snapshot);
436
481
  seeded = true;
@@ -438,7 +483,6 @@ export async function exerciseAdapter(adapter, context, options = {}) {
438
483
  // a state-replacing kind is dropped, since the snapshot carries that state and must account for
439
484
  // it; the rest -- a dial's outcome, a diagnostic, an announcement, a queue summary -- report
440
485
  // transactions no snapshot carries, and are applied after it, in order.
441
- ruleEvaluated("snapshot.accounts.task", "snapshot.accounts.ended");
442
486
  const carried = new Set();
443
487
  for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
444
488
  if (isRecord(task) && typeof task.allocationId === "string")
@@ -450,6 +494,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
450
494
  violations.push(...stream.apply(held), ...breaks.apply(held));
451
495
  continue;
452
496
  }
497
+ // Evaluated where a superseded event was held: with none, the question was never asked.
498
+ ruleEvaluated("snapshot.accounts.task", "snapshot.accounts.ended");
453
499
  if ((event.type === "task-offered" || event.type === "task-updated") && isRecord(event.task) && typeof event.task.allocationId === "string" && !carried.has(event.task.allocationId)) {
454
500
  violations.push({ rule: "snapshot.accounts.task", path: "snapshot.tasks",
455
501
  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` });
@@ -461,36 +507,20 @@ export async function exerciseAdapter(adapter, context, options = {}) {
461
507
  }
462
508
  duringRead.length = 0;
463
509
  requireCapabilityMethods(live, current().capabilities);
464
- if (publishesUserIds(snapshot)) {
465
- requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
466
- // Required by presence is not enough: the names the snapshot published are looked up, and what
467
- // comes back is held to the shape -- an empty answer to a roster of colleagues is named.
468
- const named = userIdsIn(snapshot);
469
- if (typeof live.describeUsers === "function" && named.length > 0) {
470
- let described;
471
- try {
472
- described = await live.describeUsers(named);
473
- violations.push(...validateDescribedUsers(described, named, "connection.describeUsers"));
474
- if (Array.isArray(described) && described.length === 0) {
475
- violations.push({ rule: "connection.describeUsers.empty", path: "connection.describeUsers",
476
- message: `asked about ${named.join(", ")}, whom the snapshot itself named, the provider described nobody` });
477
- }
478
- }
479
- catch (error) {
480
- violations.push({ rule: "connection.describeUsers.rejected", path: "connection.describeUsers", message: `describeUsers rejected rather than answered: ${String(error)}` });
481
- }
482
- }
483
- }
484
510
  // Capacity is stated, not requested: nothing may be allocated until it is, so a connection
485
511
  // that will not accept one is a connection nothing can be given to.
486
512
  // The guide's obligation on a voice adapter: consult the host before declaring the agent
487
513
  // ready, and on every change. An adapter that never asked cannot have.
488
- if (adapter.manifest.channel === "voice" && !consulted) {
489
- violations.push({
490
- rule: "connection.host.consulted",
491
- path: "connection.host",
492
- message: "a voice adapter consults the host's report before declaring the agent ready, and this one never asked",
493
- });
514
+ // The obligation has two halves: read the report before declaring the agent ready, and hear of
515
+ // every change after. An adapter that did one and not the other consulted the host once, or never.
516
+ ruleEvaluated("connection.host.consulted", "connection.host.subscribed");
517
+ if (adapter.manifest.channel === "voice" && !consulted.report) {
518
+ violations.push({ rule: "connection.host.consulted", path: "connection.host",
519
+ message: "a voice adapter reads the host's report before it declares the agent ready to its platform, and this one never asked" });
520
+ }
521
+ if (adapter.manifest.channel === "voice" && !consulted.subscribe) {
522
+ violations.push({ rule: "connection.host.subscribed", path: "connection.host",
523
+ message: "a voice adapter subscribes to the host's report so it hears of every change, and this one never did" });
494
524
  }
495
525
  capacityStated = 1;
496
526
  const capacity = await connection.setCapacity({ count: capacityStated });
@@ -508,19 +538,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
508
538
  message: `the host sent ${context.timeZone} at connect and the identity says ${String(current().identity.timeZone)}: the provider republishes the agent's time zone on the identity`,
509
539
  });
510
540
  }
511
- // A refusal is read only from a result that has the shape of one.
512
- if (malformed.length === 0 && capacity.status === "failed") {
513
- violations.push({
514
- rule: "connection.setCapacity.failed",
515
- path: "connection.setCapacity",
516
- message: `the provider would not accept a capacity: ${capacity.failure.code}`,
517
- });
518
- }
519
541
  if (options.drive) {
520
542
  const localAudio = isRecord(first) && isRecord(first.audio) && isRecord(first.audio.input) && first.audio.input.status === "available"
521
543
  ? first.audio.input.localAudio : undefined;
522
544
  violations.push(...await driveOneCall({
523
545
  connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
546
+ everHeldForTask: (taskId) => [...watched.everHeld].some(key => namesTask(key, taskId)), snapshotRead,
524
547
  timeoutMs: options.driveTimeoutMs ?? 5000,
525
548
  context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, handOver,
526
549
  streams: { stream, breaks },
@@ -532,19 +555,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
532
555
  // five to three, so the exercise moves the axis both ways. Zero is host-stopped: the agent's
533
556
  // capacity is elsewhere, and the provider allocates nothing and refuses nothing for it. Any offer
534
557
  // after a lower count is caught against it (stream.taskOffered.overCapacity).
535
- ruleEvaluated("connection.setCapacity.lowered", "connection.setCapacity.zero");
558
+ // A capacity is taken, never refused: each restatement is answered applied and nothing else
559
+ // (validateResult), and any offer after a lower count is caught against it.
536
560
  for (const count of clientLive ? [2, 1, 0] : []) {
537
561
  capacityStated = count;
538
562
  const restated = await connection.setCapacity({ count });
539
- const shape = validateResult(restated, "setCapacity", "connection.setCapacity");
540
- violations.push(...shape);
541
- if (shape.length === 0 && restated.status === "failed") {
542
- violations.push({ rule: count === 0 ? "connection.setCapacity.zero" : "connection.setCapacity.lowered", path: "connection.setCapacity",
543
- message: count === 0
544
- ? `the provider would not take a capacity of zero: ${restated.failure.code}; zero is host-stopped, the agent's capacity being elsewhere, and a provider allocates nothing and refuses nothing for it`
545
- : `the provider would not take a capacity of ${count} after a higher one: ${restated.failure.code}; capacity supersedes, and a decrease is as ordinary as an increase` });
546
- break;
547
- }
563
+ violations.push(...validateResult(restated, "setCapacity", "connection.setCapacity"));
548
564
  }
549
565
  }
550
566
  finally {
@@ -737,6 +753,10 @@ export function assertAuthenticationRestoreAndExpiry(states) {
737
753
  }
738
754
  }
739
755
  /** The tasks an envelope carries: the one a task event names, or a snapshot event's list. */
756
+ /** Whether a store key names the task: the id as a delimited token, never as a run of characters inside another id. */
757
+ function namesTask(key, taskId) {
758
+ return key.split(/[^A-Za-z0-9_-]+/).includes(taskId);
759
+ }
740
760
  /** Whether a task's party stands locked on its number or email, which is what obliges a run to state the locked values. */
741
761
  function locksParty(task) {
742
762
  if (!isRecord(task) || !isRecord(task.party))
@@ -794,7 +814,9 @@ export function assertCapabilityWithdrawal(states, snapshot, manifest) {
794
814
  (before.team !== undefined && after.team === undefined) ||
795
815
  (before.team?.breakControl === true && after.team?.breakControl !== true) ||
796
816
  (before.team?.leadAssistControl === true && after.team?.leadAssistControl !== true) ||
797
- (before.team?.monitorControl !== undefined && after.team?.monitorControl === undefined);
817
+ (before.team?.monitorControl !== undefined && after.team?.monitorControl === undefined) ||
818
+ (before.team?.policyControl === true && after.team?.policyControl !== true) ||
819
+ (before.preferences ?? []).some(was => !(after.preferences ?? []).some(now => now.id === was.id));
798
820
  if (!withdrawn) {
799
821
  throw new Error("Capability withdrawal must end with at least one capability the first login declared withdrawn");
800
822
  }
@@ -1013,10 +1035,16 @@ export class TaskStream {
1013
1035
  return false;
1014
1036
  return task.onCall.some(entry => isRecord(entry) && typeof entry.dialId === "string");
1015
1037
  }
1016
- /** Whether the update shows the party being dialled again -- a connect-back, a platform's callback -- the one thing that brings a completing task back. */
1017
- static partyDialled(task) {
1018
- return isRecord(task) && Array.isArray(task.onCall)
1019
- && task.onCall.some(entry => isRecord(entry) && entry.role === "party" && typeof entry.stage === "string");
1038
+ /**
1039
+ * Whether the update shows the party being dialled again -- a connect-back, a platform's callback --
1040
+ * the one thing that brings a completing task back: the party ringing, or joined by a dial whose
1041
+ * answered outcome this stream saw. A stale copy carrying `joined` from a dial long over is not that.
1042
+ */
1043
+ partyDialled(task) {
1044
+ if (!isRecord(task) || !Array.isArray(task.onCall))
1045
+ return false;
1046
+ return task.onCall.some(entry => isRecord(entry) && entry.role === "party"
1047
+ && (entry.stage === "ringing" || (entry.stage === "joined" && typeof entry.dialId === "string" && this.dials.get(entry.dialId) === "answered")));
1020
1048
  }
1021
1049
  /** The entries of a task's record, each by step and instant, or undefined where the task carries no record. */
1022
1050
  static record(task) {
@@ -1043,7 +1071,9 @@ export class TaskStream {
1043
1071
  stages.set(entry.dialId, entry.stage);
1044
1072
  }
1045
1073
  }
1046
- return { channel: String(isRecord(task) ? task.channel : undefined), completionMode: String(isRecord(task) ? task.completionMode : undefined),
1074
+ const partyRingingByHost = isRecord(task) && Array.isArray(task.onCall)
1075
+ && task.onCall.some(entry => isRecord(entry) && entry.role === "party" && entry.stage === "ringing" && typeof entry.dialId === "string");
1076
+ return { partyRingingByHost, channel: String(isRecord(task) ? task.channel : undefined), completionMode: String(isRecord(task) ? task.completionMode : undefined),
1047
1077
  wrapAllowance: isRecord(task) && typeof task.wrapAllowance === "number" ? task.wrapAllowance : undefined,
1048
1078
  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) };
1049
1079
  }
@@ -1077,7 +1107,7 @@ export class TaskStream {
1077
1107
  const to = String(task.phase);
1078
1108
  const reachable = REACHABLE_PHASES[was.phase];
1079
1109
  if (reachable !== undefined && TASK_PHASES_ORDERED.includes(to) && !reachable.has(to)) {
1080
- const connectingBack = was.phase === "completing" && TaskStream.partyDialled(task) && (to === "in-progress" || to === "paused");
1110
+ const connectingBack = was.phase === "completing" && this.partyDialled(task) && (to === "in-progress" || to === "paused");
1081
1111
  if (!connectingBack) {
1082
1112
  refuse("stream.snapshot.phase", `${at}.tasks[${index}].phase`, `${task.id} was ${was.phase} and the snapshot says ${to}: a task does not go backwards, on an update or on a resync`);
1083
1113
  }
@@ -1138,11 +1168,13 @@ export class TaskStream {
1138
1168
  case "task-updated":
1139
1169
  if (id === undefined)
1140
1170
  break;
1141
- ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.allocation", "stream.taskUpdated.mediaOpen", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
1171
+ ruleEvaluated("stream.taskUpdated.unknown");
1142
1172
  if (known === undefined) {
1143
1173
  refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
1144
1174
  break;
1145
1175
  }
1176
+ // The rules about a known task are evaluated only once there is one.
1177
+ 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");
1146
1178
  // Terms once read stay read. A re-read that fails is not a new fact about the task, so the
1147
1179
  // last statement stands and the failure is a diagnostic; undetermined is a place a task
1148
1180
  // starts from, never one it returns to.
@@ -1164,7 +1196,7 @@ export class TaskStream {
1164
1196
  const to = isRecord(event.task) ? String(event.task.phase) : "";
1165
1197
  const reachable = REACHABLE_PHASES[from];
1166
1198
  if (reachable !== undefined && TASK_PHASES_ORDERED.includes(to) && !reachable.has(to)) {
1167
- const connectingBack = from === "completing" && TaskStream.partyDialled(event.task) && (to === "in-progress" || to === "paused");
1199
+ const connectingBack = from === "completing" && this.partyDialled(event.task) && (to === "in-progress" || to === "paused");
1168
1200
  if (!connectingBack) {
1169
1201
  refuse("stream.taskUpdated.phase", `${at}.task.phase`, `${id} was ${from} and the update says ${to}: a task does not go backwards, and ${from === "completing" ? "only the party being dialled again, a connect-back or a callback, with its stage on the call, brings a completing task back" : `${to} is not reachable from ${from}`}`);
1170
1202
  }
@@ -1186,9 +1218,10 @@ export class TaskStream {
1186
1218
  refuse("stream.taskUpdated.mediaOpen", `${at}.task.phase`, `${id} moves to completing with its media still started: the audio ends first, on task-media-ended, and a wrap-up with the call still up is audio that never ended`);
1187
1219
  }
1188
1220
  }
1221
+ // The connect-back that brings a completing task back is the one exception here too.
1189
1222
  if (known.media === "ended") {
1190
1223
  const phase = isRecord(event.task) ? String(event.task.phase) : "";
1191
- if (phase !== "completing") {
1224
+ if (phase !== "completing" && !(known.phase === "completing" && this.partyDialled(event.task))) {
1192
1225
  refuse("stream.taskMediaEnded.follow", `${at}.task.phase`, `after its media ended, ${id} completes or ends; ${phase} is a phase the audio does not decide`);
1193
1226
  }
1194
1227
  }
@@ -1207,13 +1240,18 @@ export class TaskStream {
1207
1240
  if (stage === "joined" && known.stages.get(dialId) === "ringing" && this.dials.get(dialId) !== "answered") {
1208
1241
  refuse("stream.taskUpdated.stage", `${at}.task.onCall`, `a task-updated re-states a stage, it does not move it: ${dialId} was ringing and the update says joined before any answered dial-outcome for it`);
1209
1242
  }
1243
+ // joined is transient: it appears on the publication that follows the answered outcome and
1244
+ // on no later one, so a stale copy still carrying it cannot pass as a connect-back.
1245
+ if (stage === "joined" && known.stages.get(dialId) === "joined") {
1246
+ refuse("stream.taskUpdated.stage.lingering", `${at}.task.onCall`, `${dialId} was already published joined and the update says it again: joined is transient, stated once on the publication after the answered outcome, and a party with no stage is on the call`);
1247
+ }
1210
1248
  }
1211
1249
  this.tasks.set(id, next);
1212
1250
  }
1213
1251
  this.noteDials(event.task);
1214
1252
  break;
1215
1253
  case "task-media-started":
1216
- ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate", "stream.allocation.ended", "stream.allocation.unknown");
1254
+ ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate", "stream.taskMedia.channel", "stream.allocation.ended", "stream.allocation.unknown");
1217
1255
  if (id === undefined)
1218
1256
  break;
1219
1257
  if (known === undefined) {
@@ -1222,7 +1260,13 @@ export class TaskStream {
1222
1260
  }
1223
1261
  if (!this.namesTheOpenLife(event, known, at, refuse))
1224
1262
  break;
1225
- if (!AT_WORK.has(known.phase)) {
1263
+ // Ring-back is audio: a task whose party the host is dialling has media from dialling on,
1264
+ // whatever its phase; every other task not at work has none.
1265
+ if (known.channel !== "voice") {
1266
+ refuse("stream.taskMedia.channel", `${at}.type`, "only a voice task has media transitions");
1267
+ break;
1268
+ }
1269
+ if (!AT_WORK.has(known.phase) && !known.partyRingingByHost) {
1226
1270
  refuse("stream.taskMediaStarted.beforeWork", `${at}.taskId`, `media cannot arrive on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
1227
1271
  }
1228
1272
  if (known.media === "started") {
@@ -1231,7 +1275,7 @@ export class TaskStream {
1231
1275
  known.media = "started";
1232
1276
  break;
1233
1277
  case "task-media-ended":
1234
- ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent", "stream.allocation.ended", "stream.allocation.unknown");
1278
+ ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent", "stream.taskMedia.channel", "stream.allocation.ended", "stream.allocation.unknown");
1235
1279
  if (id === undefined)
1236
1280
  break;
1237
1281
  if (known === undefined) {
@@ -1240,6 +1284,10 @@ export class TaskStream {
1240
1284
  }
1241
1285
  if (!this.namesTheOpenLife(event, known, at, refuse))
1242
1286
  break;
1287
+ if (known.channel !== "voice") {
1288
+ refuse("stream.taskMedia.channel", `${at}.type`, "only a voice task has media transitions");
1289
+ break;
1290
+ }
1243
1291
  if (!WORK_BEGUN.has(known.phase)) {
1244
1292
  refuse("stream.taskMediaEnded.beforeWork", `${at}.taskId`, `media cannot end on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
1245
1293
  }
@@ -1259,6 +1307,13 @@ export class TaskStream {
1259
1307
  // A late ending for a life that is over must not end the life that is open under the same id.
1260
1308
  if (!this.namesTheOpenLife(event, known, at, refuse))
1261
1309
  break;
1310
+ // A voice task ends after its audio ends, never around it, whatever the outcome: a take-over
1311
+ // and a lead leaving included. An ending with the audio still up leaves the host holding an
1312
+ // open media session and an open leg on a task that no longer exists.
1313
+ ruleEvaluated("stream.taskEnded.mediaOpen");
1314
+ if (known.media === "started") {
1315
+ refuse("stream.taskEnded.mediaOpen", `${at}.taskId`, `${id} ended with its media still started: the audio ends first, on task-media-ended, whatever the outcome`);
1316
+ }
1262
1317
  // Off voice there is no media event, so the update that moves a task to completing is the
1263
1318
  // provider's word that handling ended and the moment the wrap allowance starts. A provider
1264
1319
  // that completes the task itself with an allowance to run has to have started the clock:
@@ -1332,8 +1387,11 @@ async function driveOneCall(drive) {
1332
1387
  };
1333
1388
  drive.waiters.add(waiter);
1334
1389
  });
1390
+ // The call's media session, opened on the first client and opened again on a reloaded second.
1391
+ let session;
1335
1392
  const recordSurvivesReload = async (at) => {
1336
- ruleEvaluated("drive.reload.rejected", "drive.reload.manifest", "drive.reload.login", "drive.reload.snapshot", "drive.reload.allocation", "drive.reload.history", "drive.reload.handover");
1393
+ // Each reload rule is marked where it is decided, never up front: a rebuild that throws has looked at nothing else.
1394
+ ruleEvaluated("drive.reload.rejected");
1337
1395
  let again;
1338
1396
  try {
1339
1397
  again = drive.rebuild();
@@ -1343,48 +1401,54 @@ async function driveOneCall(drive) {
1343
1401
  return;
1344
1402
  }
1345
1403
  // The same provider, built again: a different manifest is a different adapter, and proves nothing about this one.
1404
+ ruleEvaluated("drive.reload.manifest");
1346
1405
  if (again.manifest.id !== drive.manifest.id) {
1347
1406
  refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
1348
1407
  return;
1349
1408
  }
1350
1409
  // The first client dies first, as it does on a reload: from here the run has no connection until the second stands.
1410
+ ruleEvaluated("drive.reload.handover");
1351
1411
  if (!await drive.handOver.down()) {
1352
1412
  refuse("drive.reload.handover", "drive.reload", "taking the first client down threw: an unsubscribe, disconnect() or close() failed");
1353
1413
  }
1354
1414
  try {
1355
- const session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
1415
+ const restoredSession = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
1356
1416
  // The reload is a restore before it is anything else: the second session stands authenticated
1357
1417
  // as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
1358
- const restored = await session.state();
1418
+ const restored = await restoredSession.state();
1359
1419
  found.push(...validateAuthenticationState(restored, "drive.reload.login"));
1420
+ ruleEvaluated("drive.reload.login");
1360
1421
  const same = isRecord(restored) && restored.status === "authenticated" && isRecord(restored.identity) && restored.identity.id === drive.reader().self;
1361
1422
  if (!same) {
1362
1423
  refuse("drive.reload.login", "drive.reload.login", `a second adapter built from the same login and secrets did not restore it: the session says ${String(isRecord(restored) ? restored.status : restored)}${isRecord(restored) && isRecord(restored.identity) ? ` as ${String(restored.identity.id)}` : ""}, and the login is ${String(drive.reader().self)}`);
1363
1424
  // A reload that cannot restore the login is a sign-in screen, not a connection: the run ends here.
1364
1425
  try {
1365
- await session.close();
1426
+ await restoredSession.close();
1366
1427
  }
1367
1428
  catch { /* the session that would not restore is closed as far as it can be */ }
1368
1429
  return;
1369
1430
  }
1370
1431
  const second = await again.connect(drive.context);
1371
- // From here the second client is the run's connection: what it publishes is validated as before.
1372
- drive.handOver.up(session, second);
1432
+ // From here the second client is the run's connection, held to everything the first was on
1433
+ // connect: what it publishes is validated as before.
1434
+ await drive.handOver.up(restoredSession, second);
1373
1435
  drive.connection = second;
1374
1436
  const snapshot = await second.snapshot();
1375
- // The second adapter's snapshot is held to everything a first one is, and the streams take it
1437
+ // The second adapter's snapshot is read as the connect snapshot was, and the streams take it
1376
1438
  // as the state now the way they take any resync: held to what they knew, then replaced. A reload
1377
1439
  // is a place the stream's rules keep working, not one where they all stop.
1378
- found.push(...validateSnapshot(snapshot, again.manifest, "drive.reload.snapshot", drive.reader()));
1440
+ await drive.snapshotRead(second, snapshot, "drive.reload.snapshot");
1379
1441
  found.push(...drive.streams.stream.resync(snapshot, "drive.reload.snapshot"));
1380
1442
  drive.streams.breaks.seed(snapshot);
1381
1443
  const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
1382
1444
  ? snapshot.tasks.find(task => isRecord(task) && task.id === taskId) : undefined;
1445
+ ruleEvaluated("drive.reload.snapshot");
1383
1446
  if (carried === undefined) {
1384
1447
  refuse("drive.reload.snapshot", "drive.reload.snapshot", `a second adapter built from the same login does not carry ${taskId} on its snapshot, and the task is still open`);
1385
1448
  return;
1386
1449
  }
1387
1450
  // The allocation is part of the task, not memory beside it: a reload brings the same life back.
1451
+ ruleEvaluated("drive.reload.allocation", "drive.reload.history");
1388
1452
  if (carried.allocationId !== allocationOf()) {
1389
1453
  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`);
1390
1454
  }
@@ -1405,8 +1469,29 @@ async function driveOneCall(drive) {
1405
1469
  else if (leg.mutedBy !== "host") {
1406
1470
  refuse("drive.reload.history", "drive.reload.history", `the reloaded record's muted leg at ${at} says mutedBy ${String(leg.mutedBy)}; the host reported host`);
1407
1471
  }
1408
- // The drive goes on with the task as the reloaded client holds it.
1472
+ // The drive goes on with the task as the reloaded client holds it. The first client's media
1473
+ // session died with it: a host opens media on a task arriving with media started on a
1474
+ // snapshot, so the drive opens it again on the second and holds the answer as it held the first.
1409
1475
  task = carried;
1476
+ if (drive.softphone && carried.media === "started") {
1477
+ if (session !== undefined && typeof session.close === "function") {
1478
+ try {
1479
+ session.close();
1480
+ }
1481
+ catch {
1482
+ refuse("drive.openMedia.close", "drive.openMedia", "the first client's media session threw on close");
1483
+ }
1484
+ }
1485
+ session = undefined;
1486
+ ruleEvaluated("drive.reload.openMedia");
1487
+ const reopened = await second.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
1488
+ const malformed = validateResult(reopened, "openMedia", "drive.reload.openMedia");
1489
+ found.push(...malformed);
1490
+ if (malformed.length === 0 && isRecord(reopened) && reopened.status === "opened")
1491
+ session = reopened.session;
1492
+ else if (malformed.length === 0)
1493
+ refuse("drive.reload.openMedia", "drive.reload.openMedia", `a second adapter built from the same login could not open the audio of ${taskId}, which its own snapshot carries with media started`);
1494
+ }
1410
1495
  }
1411
1496
  catch (error) {
1412
1497
  refuse("drive.reload.rejected", "drive.reload", `the second adapter rejected rather than answered: ${String(error)}`);
@@ -1417,11 +1502,15 @@ async function driveOneCall(drive) {
1417
1502
  // inherited by the next offer of the same id, since a platform retires an id minutes after closing it.
1418
1503
  let retainedAtEnd;
1419
1504
  const storeReleased = async () => {
1420
- ruleEvaluated("drive.store.retained");
1421
1505
  await Promise.resolve();
1422
1506
  await Promise.resolve();
1423
- const retained = [...drive.held].filter(key => key.includes(taskId));
1507
+ // A task's key names the task delimited, not as a run of characters inside another id, and the
1508
+ // rule is evaluated only where the store was ever seen to hold such a key: an adapter whose keys
1509
+ // name nothing the harness can see leaves the rule unasked, which the result says.
1510
+ const retained = [...drive.held].filter(key => namesTask(key, taskId));
1424
1511
  retainedAtEnd = new Set(retained);
1512
+ if (drive.everHeldForTask(taskId))
1513
+ ruleEvaluated("drive.store.retained");
1425
1514
  if (retained.length > 0) {
1426
1515
  refuse("drive.store.retained", "drive.store", `${taskId} has ended and the login's store still holds ${retained.join(", ")}: a task's keys go with the task, or the next offer of the same id inherits them`);
1427
1516
  }
@@ -1432,7 +1521,7 @@ async function driveOneCall(drive) {
1432
1521
  if (retainedAtEnd === undefined)
1433
1522
  return;
1434
1523
  ruleEvaluated("drive.store.late");
1435
- const late = [...drive.held].filter(key => key.includes(taskId) && !retainedAtEnd.has(key));
1524
+ const late = [...drive.held].filter(key => namesTask(key, taskId) && !retainedAtEnd.has(key));
1436
1525
  if (late.length > 0) {
1437
1526
  refuse("drive.store.late", "drive.store", `${taskId} had ended with its keys gone, and the login's store now holds ${late.join(", ")}: something wrote about the task after its end`);
1438
1527
  }
@@ -1469,11 +1558,11 @@ async function driveOneCall(drive) {
1469
1558
  // Every command the drive sends is validated against the task as published, and its answer for its method.
1470
1559
  ruleEvaluated("drive.timeout");
1471
1560
  const send = async (command, dialId) => {
1472
- ruleEvaluated("drive.command.rejected", "drive.command.failed");
1473
1561
  const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`);
1474
1562
  found.push(...own);
1475
1563
  if (own.length > 0)
1476
1564
  return undefined;
1565
+ ruleEvaluated("drive.command.rejected");
1477
1566
  let result;
1478
1567
  try {
1479
1568
  result = await drive.connection.execute({ taskId, allocationId: allocationOf(), command });
@@ -1482,6 +1571,7 @@ async function driveOneCall(drive) {
1482
1571
  refuse("drive.command.rejected", `drive.command.${String(command.type)}`, `execute rejected rather than answered: ${String(error)}`);
1483
1572
  return undefined;
1484
1573
  }
1574
+ ruleEvaluated("drive.command.failed");
1485
1575
  found.push(...validateResult(result, "execute", `drive.command.${String(command.type)}.result`, dialId));
1486
1576
  if (isRecord(result) && result.status === "failed") {
1487
1577
  refuse("drive.command.failed", `drive.command.${String(command.type)}`, `the provider refused ${String(command.type)} on a task that offered it: ${String(isRecord(result.failure) ? result.failure.code : result.failure)}`);
@@ -1538,7 +1628,6 @@ async function driveOneCall(drive) {
1538
1628
  return found;
1539
1629
  }
1540
1630
  // 3. On a softphone, the audio arrives and the host opens it.
1541
- let session;
1542
1631
  if (drive.softphone && latestTask().phase === "in-progress") {
1543
1632
  if (latestTask().media !== "started") {
1544
1633
  const started = await waitFor("task-media-started for the driven task", envelope => {
@@ -1550,16 +1639,24 @@ async function driveOneCall(drive) {
1550
1639
  cursor = Math.max(cursor, started.at);
1551
1640
  }
1552
1641
  const opened = await drive.connection.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
1553
- found.push(...validateResult(opened, "openMedia", "drive.openMedia"));
1554
- if (isRecord(opened) && opened.status === "opened")
1642
+ const malformed = validateResult(opened, "openMedia", "drive.openMedia");
1643
+ found.push(...malformed);
1644
+ if (malformed.length === 0 && isRecord(opened) && opened.status === "opened")
1555
1645
  session = opened.session;
1556
- else if (isRecord(opened))
1646
+ else if (malformed.length === 0 && isRecord(opened))
1557
1647
  refuse("drive.openMedia.unavailable", "drive.openMedia", "a softphone login's adapter could not open the call's audio");
1558
1648
  }
1559
1649
  // 3b. The microphone is the host's. With the audio open, the drive mutes it for a moment and
1560
1650
  // reports the leg the provider's record would otherwise miss -- begun, then ended -- and
1561
1651
  // expects each report recorded. If the provider restates the task's record afterwards, the leg is in it.
1562
1652
  const reportedLegs = [];
1653
+ /** The duration the provider's record states for the leg at `at`, as the task stands now. */
1654
+ const closedAs = (at) => {
1655
+ const history = latestTask().handlingHistory;
1656
+ const leg = isRecord(history) && Array.isArray(history.steps)
1657
+ ? history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at) : undefined;
1658
+ return typeof leg?.seconds === "number" ? leg.seconds : undefined;
1659
+ };
1563
1660
  const canRecordMute = session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function";
1564
1661
  const report = async (body) => {
1565
1662
  ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
@@ -1583,6 +1680,9 @@ async function driveOneCall(drive) {
1583
1680
  }
1584
1681
  };
1585
1682
  const setMuted = (muted) => {
1683
+ // No session, nothing to mute: a reload whose second client could not open the audio has been named already.
1684
+ if (session === undefined)
1685
+ return;
1586
1686
  try {
1587
1687
  session.setMuted(muted);
1588
1688
  }
@@ -1627,8 +1727,9 @@ async function driveOneCall(drive) {
1627
1727
  // after the media has ended.
1628
1728
  if (drive.channel === "voice" && latestTask().phase === "in-progress" && offers("endCall")) {
1629
1729
  let openLeg;
1630
- if (canRecordMute) {
1631
- openLeg = { at: new Date().toISOString(), began: Date.now() };
1730
+ if (canRecordMute && session !== undefined) {
1731
+ // The task as published before the call ends: the completing publication that follows is the restatement held to carrying this leg.
1732
+ openLeg = { at: new Date().toISOString(), began: Date.now(), task: latestTask() };
1632
1733
  setMuted(true);
1633
1734
  await report({ at: openLeg.at });
1634
1735
  }
@@ -1646,13 +1747,17 @@ async function driveOneCall(drive) {
1646
1747
  }, cursor);
1647
1748
  if (mediaEnded?.found === "ended")
1648
1749
  cursor = mediaEnded.at;
1750
+ const completing = await updated(t => t.phase === "completing", "the task completing after its media ended");
1649
1751
  if (openLeg !== undefined) {
1650
1752
  setMuted(false);
1651
- // Stated at the record's grain: a leg that rounds to nought is 1.
1753
+ // The provider closed this leg at media end, in the completing publication; the host's report,
1754
+ // stated at the record's grain (a leg that rounds to nought is 1), changes nothing, and the
1755
+ // record as it stands now is what every later restatement is held to.
1756
+ const before = closedAs(openLeg.at);
1652
1757
  await report({ at: openLeg.at, seconds: Math.max(1, Math.round((Date.now() - openLeg.began) / 1000)), ended: true });
1653
- reportedLegs.push({ at: openLeg.at, task: latestTask() });
1758
+ reportedLegs.push({ at: openLeg.at, task: openLeg.task, closedAs: before });
1654
1759
  }
1655
- if (await updated(t => t.phase === "completing", "the task completing after its media ended") !== undefined && offers("hold")) {
1760
+ if (completing !== undefined && offers("hold")) {
1656
1761
  await holdRefusedOutsideHandling();
1657
1762
  }
1658
1763
  }
@@ -1704,11 +1809,21 @@ async function driveOneCall(drive) {
1704
1809
  if (await ended() !== undefined)
1705
1810
  await storeReleased();
1706
1811
  }
1707
- // The record kept by the provider has every leg the host reported, wherever the provider restated it.
1812
+ // The record kept by the provider has every leg the host reported, wherever the provider restated it:
1813
+ // the last publication of the task is read, whether or not the drive was waiting on it.
1814
+ const lastPublished = () => {
1815
+ for (let index = drive.events.length - 1; index >= 0; index -= 1) {
1816
+ const event = drive.events[index].event;
1817
+ if ((event.type === "task-updated" || event.type === "task-offered") && isTask(event.task) && event.task.id === taskId)
1818
+ return event.task;
1819
+ }
1820
+ return latestTask();
1821
+ };
1708
1822
  for (const reported of reportedLegs) {
1709
- if (latestTask() === reported.task)
1823
+ const published = lastPublished();
1824
+ if (published === reported.task)
1710
1825
  continue;
1711
- const history = latestTask().handlingHistory;
1826
+ const history = published.handlingHistory;
1712
1827
  if (isRecord(history) && Array.isArray(history.steps)) {
1713
1828
  const { at } = reported;
1714
1829
  const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
@@ -1718,6 +1833,12 @@ async function driveOneCall(drive) {
1718
1833
  else if (leg.mutedBy !== "host") {
1719
1834
  refuse("drive.recordStep.history", "drive.recordStep", `the record's muted leg at ${at} says mutedBy ${String(leg.mutedBy)}; the host reported host, and the record keeps the host's word`);
1720
1835
  }
1836
+ else if (reported.closedAs !== undefined) {
1837
+ ruleEvaluated("drive.recordStep.overwritten");
1838
+ if (leg.seconds !== reported.closedAs) {
1839
+ refuse("drive.recordStep.overwritten", "drive.recordStep", `the provider closed the leg at ${at} with ${reported.closedAs}s at media end and the record now says ${String(leg.seconds)}s: the host's closing report changes nothing`);
1840
+ }
1841
+ }
1721
1842
  }
1722
1843
  }
1723
1844
  // A timer fires after the microtasks: one turn of the loop is what a late persist needs to show itself.
@@ -1805,11 +1926,13 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
1805
1926
  /** The store as handed to the adapter, and the keys it holds at any moment, seen rather than reported. */
1806
1927
  function watchStore(store) {
1807
1928
  const held = new Set();
1929
+ const everHeld = new Set();
1808
1930
  return {
1809
1931
  held,
1932
+ everHeld,
1810
1933
  store: {
1811
1934
  get: key => store.get(key),
1812
- set: async (key, value) => { await store.set(key, value); held.add(key); },
1935
+ set: async (key, value) => { await store.set(key, value); held.add(key); everHeld.add(key); },
1813
1936
  delete: async (key) => { await store.delete(key); held.delete(key); },
1814
1937
  },
1815
1938
  };