@xema/omni-protocol 0.1.77 → 0.1.78

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,6 +1240,11 @@ 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
  }
@@ -1222,7 +1260,9 @@ 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 (!AT_WORK.has(known.phase) && !known.partyRingingByHost) {
1226
1266
  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
1267
  }
1228
1268
  if (known.media === "started") {
@@ -1259,6 +1299,13 @@ export class TaskStream {
1259
1299
  // A late ending for a life that is over must not end the life that is open under the same id.
1260
1300
  if (!this.namesTheOpenLife(event, known, at, refuse))
1261
1301
  break;
1302
+ // A voice task ends after its audio ends, never around it, whatever the outcome: a take-over
1303
+ // and a lead leaving included. An ending with the audio still up leaves the host holding an
1304
+ // open media session and an open leg on a task that no longer exists.
1305
+ ruleEvaluated("stream.taskEnded.mediaOpen");
1306
+ if (known.media === "started") {
1307
+ refuse("stream.taskEnded.mediaOpen", `${at}.taskId`, `${id} ended with its media still started: the audio ends first, on task-media-ended, whatever the outcome`);
1308
+ }
1262
1309
  // Off voice there is no media event, so the update that moves a task to completing is the
1263
1310
  // provider's word that handling ended and the moment the wrap allowance starts. A provider
1264
1311
  // that completes the task itself with an allowance to run has to have started the clock:
@@ -1332,8 +1379,11 @@ async function driveOneCall(drive) {
1332
1379
  };
1333
1380
  drive.waiters.add(waiter);
1334
1381
  });
1382
+ // The call's media session, opened on the first client and opened again on a reloaded second.
1383
+ let session;
1335
1384
  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");
1385
+ // Each reload rule is marked where it is decided, never up front: a rebuild that throws has looked at nothing else.
1386
+ ruleEvaluated("drive.reload.rejected");
1337
1387
  let again;
1338
1388
  try {
1339
1389
  again = drive.rebuild();
@@ -1343,48 +1393,54 @@ async function driveOneCall(drive) {
1343
1393
  return;
1344
1394
  }
1345
1395
  // The same provider, built again: a different manifest is a different adapter, and proves nothing about this one.
1396
+ ruleEvaluated("drive.reload.manifest");
1346
1397
  if (again.manifest.id !== drive.manifest.id) {
1347
1398
  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
1399
  return;
1349
1400
  }
1350
1401
  // The first client dies first, as it does on a reload: from here the run has no connection until the second stands.
1402
+ ruleEvaluated("drive.reload.handover");
1351
1403
  if (!await drive.handOver.down()) {
1352
1404
  refuse("drive.reload.handover", "drive.reload", "taking the first client down threw: an unsubscribe, disconnect() or close() failed");
1353
1405
  }
1354
1406
  try {
1355
- const session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
1407
+ const restoredSession = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
1356
1408
  // The reload is a restore before it is anything else: the second session stands authenticated
1357
1409
  // as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
1358
- const restored = await session.state();
1410
+ const restored = await restoredSession.state();
1359
1411
  found.push(...validateAuthenticationState(restored, "drive.reload.login"));
1412
+ ruleEvaluated("drive.reload.login");
1360
1413
  const same = isRecord(restored) && restored.status === "authenticated" && isRecord(restored.identity) && restored.identity.id === drive.reader().self;
1361
1414
  if (!same) {
1362
1415
  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
1416
  // A reload that cannot restore the login is a sign-in screen, not a connection: the run ends here.
1364
1417
  try {
1365
- await session.close();
1418
+ await restoredSession.close();
1366
1419
  }
1367
1420
  catch { /* the session that would not restore is closed as far as it can be */ }
1368
1421
  return;
1369
1422
  }
1370
1423
  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);
1424
+ // From here the second client is the run's connection, held to everything the first was on
1425
+ // connect: what it publishes is validated as before.
1426
+ await drive.handOver.up(restoredSession, second);
1373
1427
  drive.connection = second;
1374
1428
  const snapshot = await second.snapshot();
1375
- // The second adapter's snapshot is held to everything a first one is, and the streams take it
1429
+ // The second adapter's snapshot is read as the connect snapshot was, and the streams take it
1376
1430
  // as the state now the way they take any resync: held to what they knew, then replaced. A reload
1377
1431
  // 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()));
1432
+ await drive.snapshotRead(second, snapshot, "drive.reload.snapshot");
1379
1433
  found.push(...drive.streams.stream.resync(snapshot, "drive.reload.snapshot"));
1380
1434
  drive.streams.breaks.seed(snapshot);
1381
1435
  const carried = isRecord(snapshot) && Array.isArray(snapshot.tasks)
1382
1436
  ? snapshot.tasks.find(task => isRecord(task) && task.id === taskId) : undefined;
1437
+ ruleEvaluated("drive.reload.snapshot");
1383
1438
  if (carried === undefined) {
1384
1439
  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
1440
  return;
1386
1441
  }
1387
1442
  // The allocation is part of the task, not memory beside it: a reload brings the same life back.
1443
+ ruleEvaluated("drive.reload.allocation", "drive.reload.history");
1388
1444
  if (carried.allocationId !== allocationOf()) {
1389
1445
  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
1446
  }
@@ -1405,8 +1461,28 @@ async function driveOneCall(drive) {
1405
1461
  else if (leg.mutedBy !== "host") {
1406
1462
  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
1463
  }
1408
- // The drive goes on with the task as the reloaded client holds it.
1464
+ // The drive goes on with the task as the reloaded client holds it. The first client's media
1465
+ // session died with it: a host opens media on a task arriving with media started on a
1466
+ // snapshot, so the drive opens it again on the second and holds the answer as it held the first.
1409
1467
  task = carried;
1468
+ if (drive.softphone && carried.media === "started") {
1469
+ if (session !== undefined && typeof session.close === "function") {
1470
+ try {
1471
+ session.close();
1472
+ }
1473
+ catch {
1474
+ refuse("drive.openMedia.close", "drive.openMedia", "the first client's media session threw on close");
1475
+ }
1476
+ }
1477
+ session = undefined;
1478
+ ruleEvaluated("drive.reload.openMedia");
1479
+ const reopened = await second.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
1480
+ found.push(...validateResult(reopened, "openMedia", "drive.reload.openMedia"));
1481
+ if (isRecord(reopened) && reopened.status === "opened")
1482
+ session = reopened.session;
1483
+ else
1484
+ 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`);
1485
+ }
1410
1486
  }
1411
1487
  catch (error) {
1412
1488
  refuse("drive.reload.rejected", "drive.reload", `the second adapter rejected rather than answered: ${String(error)}`);
@@ -1417,11 +1493,15 @@ async function driveOneCall(drive) {
1417
1493
  // inherited by the next offer of the same id, since a platform retires an id minutes after closing it.
1418
1494
  let retainedAtEnd;
1419
1495
  const storeReleased = async () => {
1420
- ruleEvaluated("drive.store.retained");
1421
1496
  await Promise.resolve();
1422
1497
  await Promise.resolve();
1423
- const retained = [...drive.held].filter(key => key.includes(taskId));
1498
+ // A task's key names the task delimited, not as a run of characters inside another id, and the
1499
+ // rule is evaluated only where the store was ever seen to hold such a key: an adapter whose keys
1500
+ // name nothing the harness can see leaves the rule unasked, which the result says.
1501
+ const retained = [...drive.held].filter(key => namesTask(key, taskId));
1424
1502
  retainedAtEnd = new Set(retained);
1503
+ if (drive.everHeldForTask(taskId))
1504
+ ruleEvaluated("drive.store.retained");
1425
1505
  if (retained.length > 0) {
1426
1506
  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
1507
  }
@@ -1432,7 +1512,7 @@ async function driveOneCall(drive) {
1432
1512
  if (retainedAtEnd === undefined)
1433
1513
  return;
1434
1514
  ruleEvaluated("drive.store.late");
1435
- const late = [...drive.held].filter(key => key.includes(taskId) && !retainedAtEnd.has(key));
1515
+ const late = [...drive.held].filter(key => namesTask(key, taskId) && !retainedAtEnd.has(key));
1436
1516
  if (late.length > 0) {
1437
1517
  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
1518
  }
@@ -1469,11 +1549,11 @@ async function driveOneCall(drive) {
1469
1549
  // Every command the drive sends is validated against the task as published, and its answer for its method.
1470
1550
  ruleEvaluated("drive.timeout");
1471
1551
  const send = async (command, dialId) => {
1472
- ruleEvaluated("drive.command.rejected", "drive.command.failed");
1473
1552
  const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`);
1474
1553
  found.push(...own);
1475
1554
  if (own.length > 0)
1476
1555
  return undefined;
1556
+ ruleEvaluated("drive.command.rejected");
1477
1557
  let result;
1478
1558
  try {
1479
1559
  result = await drive.connection.execute({ taskId, allocationId: allocationOf(), command });
@@ -1482,6 +1562,7 @@ async function driveOneCall(drive) {
1482
1562
  refuse("drive.command.rejected", `drive.command.${String(command.type)}`, `execute rejected rather than answered: ${String(error)}`);
1483
1563
  return undefined;
1484
1564
  }
1565
+ ruleEvaluated("drive.command.failed");
1485
1566
  found.push(...validateResult(result, "execute", `drive.command.${String(command.type)}.result`, dialId));
1486
1567
  if (isRecord(result) && result.status === "failed") {
1487
1568
  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 +1619,6 @@ async function driveOneCall(drive) {
1538
1619
  return found;
1539
1620
  }
1540
1621
  // 3. On a softphone, the audio arrives and the host opens it.
1541
- let session;
1542
1622
  if (drive.softphone && latestTask().phase === "in-progress") {
1543
1623
  if (latestTask().media !== "started") {
1544
1624
  const started = await waitFor("task-media-started for the driven task", envelope => {
@@ -1560,6 +1640,13 @@ async function driveOneCall(drive) {
1560
1640
  // reports the leg the provider's record would otherwise miss -- begun, then ended -- and
1561
1641
  // expects each report recorded. If the provider restates the task's record afterwards, the leg is in it.
1562
1642
  const reportedLegs = [];
1643
+ /** The duration the provider's record states for the leg at `at`, as the task stands now. */
1644
+ const closedAs = (at) => {
1645
+ const history = latestTask().handlingHistory;
1646
+ const leg = isRecord(history) && Array.isArray(history.steps)
1647
+ ? history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at) : undefined;
1648
+ return typeof leg?.seconds === "number" ? leg.seconds : undefined;
1649
+ };
1563
1650
  const canRecordMute = session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function";
1564
1651
  const report = async (body) => {
1565
1652
  ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
@@ -1583,6 +1670,9 @@ async function driveOneCall(drive) {
1583
1670
  }
1584
1671
  };
1585
1672
  const setMuted = (muted) => {
1673
+ // No session, nothing to mute: a reload whose second client could not open the audio has been named already.
1674
+ if (session === undefined)
1675
+ return;
1586
1676
  try {
1587
1677
  session.setMuted(muted);
1588
1678
  }
@@ -1627,8 +1717,9 @@ async function driveOneCall(drive) {
1627
1717
  // after the media has ended.
1628
1718
  if (drive.channel === "voice" && latestTask().phase === "in-progress" && offers("endCall")) {
1629
1719
  let openLeg;
1630
- if (canRecordMute) {
1631
- openLeg = { at: new Date().toISOString(), began: Date.now() };
1720
+ if (canRecordMute && session !== undefined) {
1721
+ // The task as published before the call ends: the completing publication that follows is the restatement held to carrying this leg.
1722
+ openLeg = { at: new Date().toISOString(), began: Date.now(), task: latestTask() };
1632
1723
  setMuted(true);
1633
1724
  await report({ at: openLeg.at });
1634
1725
  }
@@ -1646,13 +1737,17 @@ async function driveOneCall(drive) {
1646
1737
  }, cursor);
1647
1738
  if (mediaEnded?.found === "ended")
1648
1739
  cursor = mediaEnded.at;
1740
+ const completing = await updated(t => t.phase === "completing", "the task completing after its media ended");
1649
1741
  if (openLeg !== undefined) {
1650
1742
  setMuted(false);
1651
- // Stated at the record's grain: a leg that rounds to nought is 1.
1743
+ // The provider closed this leg at media end, in the completing publication; the host's report,
1744
+ // stated at the record's grain (a leg that rounds to nought is 1), changes nothing, and the
1745
+ // record as it stands now is what every later restatement is held to.
1746
+ const before = closedAs(openLeg.at);
1652
1747
  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() });
1748
+ reportedLegs.push({ at: openLeg.at, task: openLeg.task, closedAs: before });
1654
1749
  }
1655
- if (await updated(t => t.phase === "completing", "the task completing after its media ended") !== undefined && offers("hold")) {
1750
+ if (completing !== undefined && offers("hold")) {
1656
1751
  await holdRefusedOutsideHandling();
1657
1752
  }
1658
1753
  }
@@ -1704,11 +1799,21 @@ async function driveOneCall(drive) {
1704
1799
  if (await ended() !== undefined)
1705
1800
  await storeReleased();
1706
1801
  }
1707
- // The record kept by the provider has every leg the host reported, wherever the provider restated it.
1802
+ // The record kept by the provider has every leg the host reported, wherever the provider restated it:
1803
+ // the last publication of the task is read, whether or not the drive was waiting on it.
1804
+ const lastPublished = () => {
1805
+ for (let index = drive.events.length - 1; index >= 0; index -= 1) {
1806
+ const event = drive.events[index].event;
1807
+ if ((event.type === "task-updated" || event.type === "task-offered") && isTask(event.task) && event.task.id === taskId)
1808
+ return event.task;
1809
+ }
1810
+ return latestTask();
1811
+ };
1708
1812
  for (const reported of reportedLegs) {
1709
- if (latestTask() === reported.task)
1813
+ const published = lastPublished();
1814
+ if (published === reported.task)
1710
1815
  continue;
1711
- const history = latestTask().handlingHistory;
1816
+ const history = published.handlingHistory;
1712
1817
  if (isRecord(history) && Array.isArray(history.steps)) {
1713
1818
  const { at } = reported;
1714
1819
  const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
@@ -1718,6 +1823,12 @@ async function driveOneCall(drive) {
1718
1823
  else if (leg.mutedBy !== "host") {
1719
1824
  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
1825
  }
1826
+ else if (reported.closedAs !== undefined) {
1827
+ ruleEvaluated("drive.recordStep.overwritten");
1828
+ if (leg.seconds !== reported.closedAs) {
1829
+ 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`);
1830
+ }
1831
+ }
1721
1832
  }
1722
1833
  }
1723
1834
  // A timer fires after the microtasks: one turn of the loop is what a late persist needs to show itself.
@@ -1805,11 +1916,13 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
1805
1916
  /** The store as handed to the adapter, and the keys it holds at any moment, seen rather than reported. */
1806
1917
  function watchStore(store) {
1807
1918
  const held = new Set();
1919
+ const everHeld = new Set();
1808
1920
  return {
1809
1921
  held,
1922
+ everHeld,
1810
1923
  store: {
1811
1924
  get: key => store.get(key),
1812
- set: async (key, value) => { await store.set(key, value); held.add(key); },
1925
+ set: async (key, value) => { await store.set(key, value); held.add(key); everHeld.add(key); },
1813
1926
  delete: async (key) => { await store.delete(key); held.delete(key); },
1814
1927
  },
1815
1928
  };