@xema/omni-protocol 0.1.66 → 0.1.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/testing.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
2
- import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateHostMute, validateLoginStore, validateHandlingReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
2
+ import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateDescribedUsers, observeRules, ruleEvaluated, validateHostMute, validateLoginStore, validateHandlingReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
3
3
  export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
4
4
  /**
5
5
  * A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
@@ -154,6 +154,8 @@ function observeEvent(envelope, seen) {
154
154
  * asynchronous one — which would let a non-conforming async adapter pass.
155
155
  */
156
156
  export async function exerciseAdapter(adapter, context, options = {}) {
157
+ const rulesEvaluated = new Set();
158
+ const stopObserving = observeRules(rule => { rulesEvaluated.add(rule); });
157
159
  const violations = [...validateManifest(adapter.manifest)];
158
160
  const events = [];
159
161
  const seen = new Set();
@@ -198,7 +200,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
198
200
  self: current().identity.id,
199
201
  capabilities: current().capabilities,
200
202
  loginId: context.loginId,
201
- autoAcceptTasks: context.autoAcceptTasks ?? true,
203
+ autoAcceptTasks: context.autoAcceptTasks,
202
204
  });
203
205
  // The optional methods are optional only until something declares a need for them. Each
204
206
  // check pairs a method with the declaration that requires it, as the guide's Live-connection
@@ -301,7 +303,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
301
303
  // record of that leg is the provider's to take; on a desk phone the host holds no microphone.
302
304
  if (softphone)
303
305
  requireMethod(live, "recordStep", "the login is on a softphone, whose microphone the host mutes");
304
- const eventIds = new Set();
306
+ const eventPayloads = new Map();
307
+ let capacityStated;
308
+ const reloading = { now: false };
305
309
  // The drive waits on events: each waiter is offered every envelope as it lands.
306
310
  const waiters = new Set();
307
311
  unsubscribe = connection.subscribe(envelope => {
@@ -309,21 +313,48 @@ export async function exerciseAdapter(adapter, context, options = {}) {
309
313
  violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader()));
310
314
  // A diagnostic is informational to a host and a failure to a conformance run: the platform
311
315
  // under test broke a rule the adapter relies on, and a green result must not paper over it.
312
- if (isRecord(envelope?.event) && envelope.event.type === "diagnostic") {
316
+ // A reload in life has no first connection: the page is gone. The drive keeps its first
317
+ // connection up while the second reads, so a platform that pushes the open task to the new
318
+ // client pushes it to the old one too, and an adapter that calls a re-offer of answered work a
319
+ // defect is right to. That diagnostic is the drive's own artefact, and is not counted while the
320
+ // second adapter is up; every other moment it is.
321
+ if (isRecord(envelope?.event) && envelope.event.type === "diagnostic" && !reloading.now) {
313
322
  violations.push({ rule: "diagnostic.raised", path: "event.diagnostic",
314
323
  message: `the provider reported a diagnostic: expected ${String(envelope.event.expected)}; observed ${String(envelope.event.observed)}` });
315
324
  }
316
325
  violations.push(...undeterminedTasks(eventTasks(envelope), "event"));
317
326
  if (eventNamesUsers(envelope))
318
327
  requireMethod(live, "describeUsers", "an event publishes a UserId");
319
- // Cross-event rules apply once the stream has a beginning: the connect snapshot.
320
- if (seeded)
321
- violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
328
+ // Work is pulled, never pushed: an offer before the host stated capacity is an allocation
329
+ // against nothing, and an offer beyond the count is one too many -- unless the task is the
330
+ // host's own dial arriving, which counts against nothing.
331
+ if (isRecord(envelope?.event) && envelope.event.type === "task-offered") {
332
+ if (capacityStated === undefined) {
333
+ violations.push({ rule: "stream.taskOffered.beforeCapacity", path: "event.task",
334
+ message: "a task was offered before the host stated any capacity: work is pulled, and nothing is allocated against a capacity nobody stated" });
335
+ }
336
+ else if (seeded && stream.openCount() >= capacityStated && !TaskStream.carriesDial(envelope.event.task)) {
337
+ violations.push({ rule: "stream.taskOffered.overCapacity", path: "event.task",
338
+ message: `a task was offered with ${stream.openCount()} already open against a stated capacity of ${capacityStated}` });
339
+ }
340
+ }
341
+ // A re-delivered envelope is harmless and applied once; the same id carrying a different
342
+ // payload is a reused id, which no dedupe can make harmless, and is named.
322
343
  if (typeof envelope?.id === "string") {
323
- if (eventIds.has(envelope.id))
344
+ const payload = JSON.stringify(envelope.event);
345
+ const earlier = eventPayloads.get(envelope.id);
346
+ if (earlier !== undefined) {
347
+ if (earlier !== payload) {
348
+ violations.push({ rule: "event.id.reused", path: "event.id",
349
+ message: `envelope ${envelope.id} was delivered again with a different event: an id names one event for the life of the login` });
350
+ }
324
351
  return;
325
- eventIds.add(envelope.id);
352
+ }
353
+ eventPayloads.set(envelope.id, payload);
326
354
  }
355
+ // Cross-event rules apply once the stream has a beginning: the connect snapshot.
356
+ if (seeded)
357
+ violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
327
358
  events.push(envelope);
328
359
  for (const waiter of waiters)
329
360
  waiter(envelope);
@@ -336,8 +367,26 @@ export async function exerciseAdapter(adapter, context, options = {}) {
336
367
  breaks.seed(snapshot);
337
368
  seeded = true;
338
369
  requireCapabilityMethods(live, current().capabilities);
339
- if (publishesUserIds(snapshot))
370
+ if (publishesUserIds(snapshot)) {
340
371
  requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
372
+ // Required by presence is not enough: the names the snapshot published are looked up, and what
373
+ // comes back is held to the shape -- an empty answer to a roster of colleagues is named.
374
+ const named = userIdsIn(snapshot);
375
+ if (typeof live.describeUsers === "function" && named.length > 0) {
376
+ let described;
377
+ try {
378
+ described = await live.describeUsers(named);
379
+ violations.push(...validateDescribedUsers(described, named, "connection.describeUsers"));
380
+ if (Array.isArray(described) && described.length === 0) {
381
+ violations.push({ rule: "connection.describeUsers.empty", path: "connection.describeUsers",
382
+ message: `asked about ${named.join(", ")}, whom the snapshot itself named, the provider described nobody` });
383
+ }
384
+ }
385
+ catch (error) {
386
+ violations.push({ rule: "connection.describeUsers.rejected", path: "connection.describeUsers", message: `describeUsers rejected rather than answered: ${String(error)}` });
387
+ }
388
+ }
389
+ }
341
390
  // Capacity is stated, not requested: nothing may be allocated until it is, so a connection
342
391
  // that will not accept one is a connection nothing can be given to.
343
392
  // The guide's obligation on a voice adapter: consult the host before declaring the agent
@@ -349,7 +398,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
349
398
  message: "a voice adapter consults the host's report before declaring the agent ready, and this one never asked",
350
399
  });
351
400
  }
352
- const capacity = await connection.setCapacity({ count: 1 });
401
+ capacityStated = 1;
402
+ const capacity = await connection.setCapacity({ count: capacityStated });
353
403
  const malformed = validateResult(capacity, "setCapacity", "connection.setCapacity");
354
404
  violations.push(...malformed);
355
405
  // The provider republishes the agent's day: once connected, the identity carries the zone the
@@ -378,7 +428,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
378
428
  violations.push(...await driveOneCall({
379
429
  connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
380
430
  timeoutMs: options.driveTimeoutMs ?? 5000,
381
- context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held,
431
+ context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, reloading,
382
432
  }));
383
433
  }
384
434
  }
@@ -425,6 +475,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
425
475
  }
426
476
  if (!options.collectOnly)
427
477
  assertNoViolations(violations);
478
+ stopObserving();
428
479
  return {
429
480
  events: events,
430
481
  authenticationState: authenticationState,
@@ -432,6 +483,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
432
483
  notExercised: CONTRACT_SUBJECTS.filter(subject => !seen.has(subject)),
433
484
  disconnectWasClean,
434
485
  violations,
486
+ rulesEvaluated,
435
487
  };
436
488
  }
437
489
  /**
@@ -448,6 +500,42 @@ function publishesUserIds(snapshot) {
448
500
  return false;
449
501
  return snapshot.tasks.some(taskNamesUsers);
450
502
  }
503
+ /** Every UserId a snapshot publishes: on the record, the room, a lead request, an imposed break, the roster. */
504
+ function userIdsIn(snapshot) {
505
+ const ids = new Set();
506
+ const add = (value) => { if (typeof value === "string" && value.length > 0)
507
+ ids.add(value); };
508
+ add(snapshot?.break?.imposed?.by);
509
+ const team = snapshot?.team;
510
+ if (isRecord(team)) {
511
+ for (const member of Array.isArray(team.members) ? team.members : [])
512
+ if (isRecord(member))
513
+ add(member.id);
514
+ for (const request of Array.isArray(team.requests) ? team.requests : [])
515
+ if (isRecord(request))
516
+ add(request.memberId);
517
+ }
518
+ for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
519
+ const t = task;
520
+ if (isRecord(t.handlingHistory) && Array.isArray(t.handlingHistory.steps))
521
+ for (const step of t.handlingHistory.steps)
522
+ if (isRecord(step))
523
+ add(step.by);
524
+ if (Array.isArray(t.onCall))
525
+ for (const entry of t.onCall)
526
+ if (isRecord(entry))
527
+ add(entry.userId);
528
+ if (isRecord(t.leadAssist))
529
+ add(t.leadAssist.leadId);
530
+ if (isRecord(t.assisting))
531
+ add(t.assisting.memberId);
532
+ if (isRecord(t.monitoring)) {
533
+ add(t.monitoring.memberId);
534
+ add(t.monitoring.agentId);
535
+ }
536
+ }
537
+ return [...ids];
538
+ }
451
539
  const teamNamesUsers = (team) => isRecord(team) && (some(team.members) || some(team.requests));
452
540
  const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.handlingHistory) && Array.isArray(task.handlingHistory.steps) && task.handlingHistory.steps.some(step => isRecord(step) && step.by !== undefined)) ||
453
541
  (isRecord(task.leadAssist) && task.leadAssist.leadId !== undefined) ||
@@ -720,9 +808,26 @@ export function assertDeniedAndRetriedBreak(approvals) {
720
808
  // completing or ending, never a phase the audio decided.
721
809
  // ---------------------------------------------------------------------------
722
810
  const WORK_BEGUN = new Set(["in-progress", "paused", "completing"]);
811
+ /** Where audio can arrive: a task at work. A completing task's call is over; a connect-back returns it to in-progress before any media. */
812
+ const AT_WORK = new Set(["in-progress", "paused"]);
723
813
  /** What a stream has said about the tasks it carries, and the rules across events. */
814
+ const TASK_PHASES_ORDERED = ["pending", "confirmed", "preview", "in-progress", "paused", "completing"];
815
+ /** Where a task may be next, by the guide's transition table, closed over the phases between two publications. */
816
+ const REACHABLE_PHASES = {
817
+ pending: new Set(["pending", "confirmed", "preview", "in-progress", "paused", "completing"]),
818
+ confirmed: new Set(["confirmed", "preview", "in-progress", "paused", "completing"]),
819
+ preview: new Set(["preview", "in-progress", "paused", "completing"]),
820
+ "in-progress": new Set(["in-progress", "paused", "completing"]),
821
+ paused: new Set(["paused", "in-progress", "completing"]),
822
+ completing: new Set(["completing"]),
823
+ };
724
824
  export class TaskStream {
725
825
  tasks = new Map();
826
+ // Every allocation this login has seen, and the ones whose task has ended: an offer never reuses
827
+ // one, an event about a task names the life that is open, and a late event for a life that ended
828
+ // is recognised as that rather than landing on the next customer under the same id.
829
+ allocations = new Set();
830
+ endedAllocations = new Set();
726
831
  // Every dial the stream can place an outcome against: one the host said it placed, or one a
727
832
  // task carried on `onCall` or in its record -- which is how a dial made before a transfer is known
728
833
  // to whoever holds the task now. `answered` or `ended` once its outcome arrived, since it comes once.
@@ -744,6 +849,36 @@ export class TaskStream {
744
849
  this.dialled(entry.dialId);
745
850
  }
746
851
  }
852
+ /**
853
+ * Whether an event about a task names the life that is open under its id. One naming a life that
854
+ * has ended is the late event the allocation exists to catch; one naming a life nobody has seen is
855
+ * a different fault. Either way it is not applied to the open task.
856
+ */
857
+ namesTheOpenLife(event, known, at, refuse) {
858
+ const named = event.allocationId;
859
+ if (typeof named !== "string" || named === known.allocation)
860
+ return true;
861
+ if (this.endedAllocations.has(named)) {
862
+ refuse("stream.allocation.ended", `${at}.allocationId`, `${String(event.type)} names allocation ${named}, a life of ${String(event.taskId)} that has ended; the life open under that id is ${known.allocation}, and a late event lands on the life it names, never the next`);
863
+ }
864
+ else {
865
+ refuse("stream.allocation.unknown", `${at}.allocationId`, `${named} is not an allocation this login has seen`);
866
+ }
867
+ return false;
868
+ }
869
+ /** How many tasks the stream currently holds open. */
870
+ openCount() { return this.tasks.size; }
871
+ /** Whether an offered task is a dial arriving -- the host's own call, a connect-back -- which counts against no capacity: an entry on its call carrying a dial. */
872
+ static carriesDial(task) {
873
+ if (!isRecord(task) || !Array.isArray(task.onCall))
874
+ return false;
875
+ return task.onCall.some(entry => isRecord(entry) && typeof entry.dialId === "string");
876
+ }
877
+ /** 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. */
878
+ static partyDialled(task) {
879
+ return isRecord(task) && Array.isArray(task.onCall)
880
+ && task.onCall.some(entry => isRecord(entry) && entry.role === "party" && typeof entry.stage === "string");
881
+ }
747
882
  /** The entries of a task's record, each by step and instant, or undefined where the task carries no record. */
748
883
  static record(task) {
749
884
  if (!isRecord(task) || !isRecord(task.handlingHistory) || !Array.isArray(task.handlingHistory.steps))
@@ -769,7 +904,7 @@ export class TaskStream {
769
904
  stages.set(entry.dialId, entry.stage);
770
905
  }
771
906
  }
772
- return { phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task) };
907
+ return { 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) };
773
908
  }
774
909
  /** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
775
910
  seed(snapshot) {
@@ -777,8 +912,11 @@ export class TaskStream {
777
912
  if (!isRecord(snapshot) || !Array.isArray(snapshot.tasks))
778
913
  return;
779
914
  for (const task of snapshot.tasks) {
780
- if (isRecord(task) && typeof task.id === "string")
915
+ if (isRecord(task) && typeof task.id === "string") {
781
916
  this.tasks.set(task.id, TaskStream.stated(task));
917
+ if (typeof task.allocationId === "string")
918
+ this.allocations.add(task.allocationId);
919
+ }
782
920
  this.noteDials(task);
783
921
  }
784
922
  }
@@ -794,6 +932,7 @@ export class TaskStream {
794
932
  const known = id === undefined ? undefined : this.tasks.get(id);
795
933
  switch (event.type) {
796
934
  case "snapshot":
935
+ ruleEvaluated("stream.snapshot.capabilitySource", "stream.snapshot.handlingHistory");
797
936
  // A snapshot replaces what is known, and still may not say a task lost terms it had read.
798
937
  if (isRecord(event.snapshot) && Array.isArray(event.snapshot.tasks)) {
799
938
  event.snapshot.tasks.forEach((task, index) => {
@@ -812,17 +951,27 @@ export class TaskStream {
812
951
  }
813
952
  this.seed(event.snapshot);
814
953
  break;
815
- case "task-offered":
954
+ case "task-offered": {
955
+ ruleEvaluated("stream.taskOffered.duplicate", "stream.taskOffered.allocation");
816
956
  if (id === undefined)
817
957
  break;
818
958
  if (known !== undefined)
819
959
  refuse("stream.taskOffered.duplicate", `${at}.task.id`, `${id} is already on the stream; an offer introduces a task once`);
960
+ const allocation = isRecord(event.task) ? event.task.allocationId : undefined;
961
+ if (typeof allocation === "string") {
962
+ if (this.allocations.has(allocation)) {
963
+ refuse("stream.taskOffered.allocation", `${at}.task.allocationId`, `${allocation} was already an allocation on this login: an allocation is minted once per offer and never reused, whatever the task id does`);
964
+ }
965
+ this.allocations.add(allocation);
966
+ }
820
967
  this.tasks.set(id, TaskStream.stated(event.task));
821
968
  this.noteDials(event.task);
822
969
  break;
970
+ }
823
971
  case "task-updated":
824
972
  if (id === undefined)
825
973
  break;
974
+ ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.allocation", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
826
975
  if (known === undefined) {
827
976
  refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
828
977
  break;
@@ -833,6 +982,27 @@ export class TaskStream {
833
982
  if ((known.source === "queue" || known.source === "ungoverned") && isRecord(event.task) && event.task.capabilitySource === "undetermined") {
834
983
  refuse("stream.taskUpdated.capabilitySource", `${at}.task.capabilitySource`, `${id} was published under ${known.source} terms and now says undetermined: terms once read stay read, and a re-read that fails is a diagnostic, not a republish`);
835
984
  }
985
+ // An update is of the life that is open. One carrying another allocation is a copy of a
986
+ // different life of this id -- a stale republish of the last customer's call, or the next one's.
987
+ if (isRecord(event.task) && String(event.task.allocationId) !== known.allocation) {
988
+ refuse("stream.taskUpdated.allocation", `${at}.task.allocationId`, `${id} is open as allocation ${known.allocation} and the update says ${String(event.task.allocationId)}: an update restates the life that is open, never another`);
989
+ }
990
+ // A task does not go backwards. The stream sees publications, not transitions, and a task may
991
+ // pass through a phase between two, so what is refused is a phase unreachable from the last
992
+ // one read by the guide's table: back to pending, back to confirmed once work began, out of
993
+ // completing except through a connect-back -- which the update itself shows, the party
994
+ // carrying the host's dial.
995
+ {
996
+ const from = known.phase;
997
+ const to = isRecord(event.task) ? String(event.task.phase) : "";
998
+ const reachable = REACHABLE_PHASES[from];
999
+ if (reachable !== undefined && TASK_PHASES_ORDERED.includes(to) && !reachable.has(to)) {
1000
+ const connectingBack = from === "completing" && TaskStream.partyDialled(event.task) && (to === "in-progress" || to === "paused");
1001
+ if (!connectingBack) {
1002
+ 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}`}`);
1003
+ }
1004
+ }
1005
+ }
836
1006
  // A record once read is not unread: an update restates it whole, or with more, never with less.
837
1007
  {
838
1008
  const lost = TaskStream.lost(known.record, TaskStream.record(event.task));
@@ -867,13 +1037,16 @@ export class TaskStream {
867
1037
  this.noteDials(event.task);
868
1038
  break;
869
1039
  case "task-media-started":
1040
+ ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate", "stream.allocation.ended", "stream.allocation.unknown");
870
1041
  if (id === undefined)
871
1042
  break;
872
1043
  if (known === undefined) {
873
1044
  refuse("stream.taskMediaStarted.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
874
1045
  break;
875
1046
  }
876
- if (!WORK_BEGUN.has(known.phase)) {
1047
+ if (!this.namesTheOpenLife(event, known, at, refuse))
1048
+ break;
1049
+ if (!AT_WORK.has(known.phase)) {
877
1050
  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`);
878
1051
  }
879
1052
  if (known.media === "started") {
@@ -882,12 +1055,15 @@ export class TaskStream {
882
1055
  known.media = "started";
883
1056
  break;
884
1057
  case "task-media-ended":
1058
+ ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent", "stream.allocation.ended", "stream.allocation.unknown");
885
1059
  if (id === undefined)
886
1060
  break;
887
1061
  if (known === undefined) {
888
1062
  refuse("stream.taskMediaEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
889
1063
  break;
890
1064
  }
1065
+ if (!this.namesTheOpenLife(event, known, at, refuse))
1066
+ break;
891
1067
  if (!WORK_BEGUN.has(known.phase)) {
892
1068
  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`);
893
1069
  }
@@ -897,15 +1073,27 @@ export class TaskStream {
897
1073
  known.media = "ended";
898
1074
  break;
899
1075
  case "task-ended":
1076
+ ruleEvaluated("stream.taskEnded.unknown", "stream.allocation.ended", "stream.allocation.unknown");
900
1077
  if (id === undefined)
901
1078
  break;
902
- if (known === undefined)
1079
+ if (known === undefined) {
903
1080
  refuse("stream.taskEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
1081
+ break;
1082
+ }
1083
+ // A late ending for a life that is over must not end the life that is open under the same id.
1084
+ if (!this.namesTheOpenLife(event, known, at, refuse))
1085
+ break;
1086
+ this.endedAllocations.add(known.allocation);
904
1087
  this.tasks.delete(id);
905
1088
  break;
906
1089
  case "dial-outcome": {
907
1090
  // An outcome ends a dial somebody placed, once. The task it names may already have ended;
908
- // a dial placed late routinely outlives its call, which is why the dial has its own identity.
1091
+ // a dial placed late routinely outlives its call, which is why the dial has its own identity,
1092
+ // and why the outcome names the allocation: the host routes it to that life, ended or not.
1093
+ ruleEvaluated("stream.allocation.unknown");
1094
+ if (typeof event.allocationId === "string" && !this.allocations.has(event.allocationId)) {
1095
+ refuse("stream.allocation.unknown", `${at}.allocationId`, `${event.allocationId} is not an allocation this login has seen`);
1096
+ }
909
1097
  if (typeof event.dialId !== "string")
910
1098
  break;
911
1099
  const dial = this.dials.get(event.dialId);
@@ -960,6 +1148,7 @@ async function driveOneCall(drive) {
960
1148
  drive.waiters.add(waiter);
961
1149
  });
962
1150
  const recordSurvivesReload = async (at) => {
1151
+ ruleEvaluated("drive.reload.rejected", "drive.reload.manifest", "drive.reload.login", "drive.reload.snapshot", "drive.reload.allocation", "drive.reload.history");
963
1152
  let again;
964
1153
  try {
965
1154
  again = drive.rebuild();
@@ -976,7 +1165,17 @@ async function driveOneCall(drive) {
976
1165
  refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
977
1166
  return;
978
1167
  }
1168
+ drive.reloading.now = true;
979
1169
  session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
1170
+ // The reload is a restore before it is anything else: the second session stands authenticated
1171
+ // as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
1172
+ const restored = await session.state();
1173
+ found.push(...validateAuthenticationState(restored, "drive.reload.login"));
1174
+ const same = isRecord(restored) && restored.status === "authenticated" && isRecord(restored.identity) && restored.identity.id === drive.reader().self;
1175
+ if (!same) {
1176
+ 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)}`);
1177
+ return;
1178
+ }
980
1179
  second = await again.connect(drive.context);
981
1180
  const snapshot = await second.snapshot();
982
1181
  // The second adapter's snapshot is held to everything a first one is.
@@ -987,6 +1186,10 @@ async function driveOneCall(drive) {
987
1186
  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`);
988
1187
  return;
989
1188
  }
1189
+ // The allocation is part of the task, not memory beside it: a reload brings the same life back.
1190
+ if (carried.allocationId !== allocationOf()) {
1191
+ 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`);
1192
+ }
990
1193
  const history = carried.handlingHistory;
991
1194
  const steps = isRecord(history) && Array.isArray(history.steps) ? history.steps : [];
992
1195
  // A record once read is not unread across a reload either: every entry the first adapter published is here.
@@ -1021,6 +1224,7 @@ async function driveOneCall(drive) {
1021
1224
  catch (error) {
1022
1225
  refuse("drive.reload.rejected", "drive.reload", `the second adapter's session threw on close: ${String(error)}`);
1023
1226
  }
1227
+ drive.reloading.now = false;
1024
1228
  }
1025
1229
  };
1026
1230
  // A task's keys are gone with the task. A task-scoped key carries the task id, so the watched
@@ -1028,6 +1232,7 @@ async function driveOneCall(drive) {
1028
1232
  // inherited by the next offer of the same id, since a platform retires an id minutes after closing it.
1029
1233
  let retainedAtEnd;
1030
1234
  const storeReleased = async () => {
1235
+ ruleEvaluated("drive.store.retained");
1031
1236
  await Promise.resolve();
1032
1237
  await Promise.resolve();
1033
1238
  const retained = [...drive.held].filter(key => key.includes(taskId));
@@ -1041,6 +1246,7 @@ async function driveOneCall(drive) {
1041
1246
  const nothingLate = () => {
1042
1247
  if (retainedAtEnd === undefined)
1043
1248
  return;
1249
+ ruleEvaluated("drive.store.late");
1044
1250
  const late = [...drive.held].filter(key => key.includes(taskId) && !retainedAtEnd.has(key));
1045
1251
  if (late.length > 0) {
1046
1252
  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`);
@@ -1060,6 +1266,7 @@ async function driveOneCall(drive) {
1060
1266
  cursor = offered.at;
1061
1267
  }
1062
1268
  const taskId = task.id;
1269
+ const allocationOf = () => String(latestTask().allocationId);
1063
1270
  const latestTask = () => task;
1064
1271
  const updated = (until, what) => waitFor(what, envelope => {
1065
1272
  const event = envelope.event;
@@ -1075,14 +1282,16 @@ async function driveOneCall(drive) {
1075
1282
  return event.type === "task-ended" && event.taskId === taskId ? event : undefined;
1076
1283
  }, cursor);
1077
1284
  // Every command the drive sends is validated against the task as published, and its answer for its method.
1285
+ ruleEvaluated("drive.timeout");
1078
1286
  const send = async (command, dialId) => {
1287
+ ruleEvaluated("drive.command.rejected", "drive.command.failed");
1079
1288
  const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`);
1080
1289
  found.push(...own);
1081
1290
  if (own.length > 0)
1082
1291
  return undefined;
1083
1292
  let result;
1084
1293
  try {
1085
- result = await drive.connection.execute({ taskId, command });
1294
+ result = await drive.connection.execute({ taskId, allocationId: allocationOf(), command });
1086
1295
  }
1087
1296
  catch (error) {
1088
1297
  refuse("drive.command.rejected", `drive.command.${String(command.type)}`, `execute rejected rather than answered: ${String(error)}`);
@@ -1124,10 +1333,11 @@ async function driveOneCall(drive) {
1124
1333
  // control still declared: a host holds it back (command.phase.handling), and an adapter that
1125
1334
  // receives it anyway must refuse it -- so the drive sends it past the validator and expects failed.
1126
1335
  const holdRefusedOutsideHandling = async () => {
1336
+ ruleEvaluated("drive.command.rejected", "drive.command.handling");
1127
1337
  const phase = String(latestTask().phase);
1128
1338
  let answer;
1129
1339
  try {
1130
- answer = await drive.connection.execute({ taskId, command: { type: "hold" } });
1340
+ answer = await drive.connection.execute({ taskId, allocationId: allocationOf(), command: { type: "hold" } });
1131
1341
  }
1132
1342
  catch (error) {
1133
1343
  refuse("drive.command.rejected", "drive.command.hold", `execute rejected rather than answered: ${String(error)}`);
@@ -1154,7 +1364,7 @@ async function driveOneCall(drive) {
1154
1364
  return found;
1155
1365
  cursor = Math.max(cursor, started.at);
1156
1366
  }
1157
- const opened = await drive.connection.openMedia?.({ taskId, localAudio: drive.localAudio });
1367
+ const opened = await drive.connection.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
1158
1368
  found.push(...validateResult(opened, "openMedia", "drive.openMedia"));
1159
1369
  if (isRecord(opened) && opened.status === "opened")
1160
1370
  session = opened.session;
@@ -1168,8 +1378,9 @@ async function driveOneCall(drive) {
1168
1378
  if (session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function") {
1169
1379
  const at = new Date().toISOString();
1170
1380
  const report = async (body) => {
1381
+ ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
1171
1382
  // The drive holds its own report to the contract before it crosses, as a host must.
1172
- const leg = { taskId, step: "muted", at, mutedBy: "host", ...body };
1383
+ const leg = { taskId, allocationId: allocationOf(), step: "muted", at, mutedBy: "host", ...body };
1173
1384
  const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
1174
1385
  found.push(...own);
1175
1386
  if (own.length > 0)
@@ -6,6 +6,14 @@ export declare class ProtocolConformanceError extends Error {
6
6
  }
7
7
  /** Throws `ProtocolConformanceError` when any violation is present. */
8
8
  export declare function assertNoViolations(violations: readonly ProtocolViolation[], summary?: string): void;
9
+ /**
10
+ * Hears every rule a validator evaluates, pass or fail, until the returned function is called. A
11
+ * green run can then say which rules it looked at rather than only which subjects it reached: a
12
+ * rule never evaluated is a visible gap, not a pass. The harness registers one for each exercise.
13
+ */
14
+ export declare function observeRules(listener: (rule: string) => void): () => void;
15
+ /** Says a rule was evaluated, for code that decides outside a collector: the stream, the drive. */
16
+ export declare function ruleEvaluated(...rules: readonly string[]): void;
9
17
  /** Every field is optional, so this checks what is present rather than what is missing. */
10
18
  export declare function validateContact(contact: unknown, path?: string): ProtocolViolation[];
11
19
  export declare function validateScheduledActivity(activity: unknown, path?: string): ProtocolViolation[];
@@ -20,7 +28,7 @@ export interface TaskValidationContext {
20
28
  channel: string;
21
29
  /** The level ids in force, from the manifest. The defaults when absent. */
22
30
  levels?: readonly string[];
23
- /** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether a pending task states its `acceptance`. */
31
+ /** `ConnectContext.autoAcceptTasks` as sent: whether a pending task states its `acceptance`. Unknown to a caller without the context, and then unchecked. */
24
32
  autoAcceptTasks?: boolean;
25
33
  /** Whether the manifest declares `dialOutcomes`. A task that may dial needs it to; absent, the question is not asked. */
26
34
  dialOutcomesDeclared?: boolean;
@@ -45,7 +53,7 @@ export interface ReaderContext {
45
53
  levels?: readonly string[];
46
54
  /** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
47
55
  loginId?: string;
48
- /** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether a pending task states its `acceptance`. */
56
+ /** `ConnectContext.autoAcceptTasks` as sent: whether a pending task states its `acceptance`. Unknown to a caller without the context, and then unchecked. */
49
57
  autoAcceptTasks?: boolean;
50
58
  }
51
59
  export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
@@ -94,6 +102,12 @@ export declare function isTimeZone(value: unknown): value is string;
94
102
  export declare function sameTimeZone(a: unknown, b: unknown): boolean;
95
103
  /** The zone a host sends at connect: required, and an IANA name. */
96
104
  export declare function validateTimeZone(value: unknown, path?: string): ProtocolViolation[];
105
+ /**
106
+ * What `describeUsers(ids)` answered: an array of users, each one asked for, each a valid identity.
107
+ * The provider may answer fewer than asked, which is what "unknown to it" looks like; it never
108
+ * answers somebody nobody asked about.
109
+ */
110
+ export declare function validateDescribedUsers(users: unknown, asked: readonly string[], path?: string): ProtocolViolation[];
97
111
  /** What a login is validated against beyond its own shape. */
98
112
  export interface LoginValidationContext {
99
113
  /** The level ids in force, from the manifest. The defaults when absent. */