@xema/omni-protocol 0.1.66 → 0.1.67

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -442,8 +442,8 @@ export type ConnectContext = {
442
442
  protocolVersion: number;
443
443
  /** The session that authenticated this connection. */
444
444
  loginId: string;
445
- /** Omni-side policy: whether the agent's tasks are accepted without asking them. */
446
- autoAcceptTasks?: boolean;
445
+ /** Omni-side policy: whether the agent's tasks are accepted without asking them. Stated, never assumed: a host says which. */
446
+ autoAcceptTasks: boolean;
447
447
  /**
448
448
  * The zone the agent's day is reckoned in, the same value passed as
449
449
  * `AuthenticationContext.timeZone`. The provider stores it on the agent and carries it on the
@@ -713,13 +713,24 @@ export declare const ON_CALL_ROLES: readonly ["party", "agent", "consulted", "co
713
713
  * either way, and the `stage` it has reached: `ringing` until the dial is answered, `joined` after,
714
714
  * stated so a snapshot says who is present without anyone having seen the outcome. Nobody ringing is
715
715
  * held. `label` names a destination -- a person, a queue -- not a phrase. At most one `party` and
716
- * one `consulted`: the consult commands name neither because there is exactly one.
716
+ * one `consulted`: the consult commands name neither because there is exactly one. The `party`
717
+ * carries a `stage` only while being dialled again on the same task -- a connect-back the host
718
+ * placed, carrying its `dialId`, or a callback the platform places itself, carrying none --
719
+ * `ringing` from the moment the dial is placed and `joined` on its answered outcome, so a host can
720
+ * show a call being placed rather than assert one that is still ringing. A party with no stage is
721
+ * on the call.
717
722
  */
718
723
  export type OnCall = {
719
724
  since: IsoTimestamp;
720
725
  held?: true;
721
726
  } & ({
722
727
  role: "party";
728
+ dialId?: never;
729
+ stage?: never;
730
+ } | {
731
+ role: "party";
732
+ stage: OnCallStage;
733
+ dialId?: DialId;
723
734
  } | {
724
735
  role: "agent";
725
736
  userId: UserId;
package/dist/testing.d.ts CHANGED
@@ -29,6 +29,12 @@ export interface AdapterContractResult {
29
29
  notExercised: readonly ContractSubject[];
30
30
  /** Every violation observed. Non-empty only when `collectOnly` suppressed the throw. */
31
31
  violations: readonly ProtocolViolation[];
32
+ /**
33
+ * Every rule the run evaluated, pass or fail: the validators' as each was applied, the stream's
34
+ * and the drive's as each case was considered. A rule absent here was never looked at, which a
35
+ * clean `violations` says nothing about; a test that needs a rule to have run asserts it here.
36
+ */
37
+ rulesEvaluated: ReadonlySet<string>;
32
38
  }
33
39
  export interface ExerciseAdapterOptions {
34
40
  /** Return violations in the result instead of throwing. Defaults to `false`. */
@@ -127,13 +133,18 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
127
133
  * both.
128
134
  */
129
135
  export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
130
- /** What a stream has said about the tasks it carries, and the rules across events. */
131
136
  export declare class TaskStream {
132
137
  private readonly tasks;
133
138
  private readonly dials;
134
139
  /** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
135
140
  dialled(dialId: string): void;
136
141
  private noteDials;
142
+ /** How many tasks the stream currently holds open. */
143
+ openCount(): number;
144
+ /** 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. */
145
+ static carriesDial(task: unknown): boolean;
146
+ /** 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. */
147
+ private static partyDialled;
137
148
  /** The entries of a task's record, each by step and instant, or undefined where the task carries no record. */
138
149
  private static record;
139
150
  /** What a restated record lost of the one read before it: nothing, or the entries by step and instant. */
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,8 @@ 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;
305
308
  // The drive waits on events: each waiter is offered every envelope as it lands.
306
309
  const waiters = new Set();
307
310
  unsubscribe = connection.subscribe(envelope => {
@@ -316,14 +319,36 @@ export async function exerciseAdapter(adapter, context, options = {}) {
316
319
  violations.push(...undeterminedTasks(eventTasks(envelope), "event"));
317
320
  if (eventNamesUsers(envelope))
318
321
  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));
322
+ // Work is pulled, never pushed: an offer before the host stated capacity is an allocation
323
+ // against nothing, and an offer beyond the count is one too many -- unless the task is the
324
+ // host's own dial arriving, which counts against nothing.
325
+ if (isRecord(envelope?.event) && envelope.event.type === "task-offered") {
326
+ if (capacityStated === undefined) {
327
+ violations.push({ rule: "stream.taskOffered.beforeCapacity", path: "event.task",
328
+ message: "a task was offered before the host stated any capacity: work is pulled, and nothing is allocated against a capacity nobody stated" });
329
+ }
330
+ else if (seeded && stream.openCount() >= capacityStated && !TaskStream.carriesDial(envelope.event.task)) {
331
+ violations.push({ rule: "stream.taskOffered.overCapacity", path: "event.task",
332
+ message: `a task was offered with ${stream.openCount()} already open against a stated capacity of ${capacityStated}` });
333
+ }
334
+ }
335
+ // A re-delivered envelope is harmless and applied once; the same id carrying a different
336
+ // payload is a reused id, which no dedupe can make harmless, and is named.
322
337
  if (typeof envelope?.id === "string") {
323
- if (eventIds.has(envelope.id))
338
+ const payload = JSON.stringify(envelope.event);
339
+ const earlier = eventPayloads.get(envelope.id);
340
+ if (earlier !== undefined) {
341
+ if (earlier !== payload) {
342
+ violations.push({ rule: "event.id.reused", path: "event.id",
343
+ message: `envelope ${envelope.id} was delivered again with a different event: an id names one event for the life of the login` });
344
+ }
324
345
  return;
325
- eventIds.add(envelope.id);
346
+ }
347
+ eventPayloads.set(envelope.id, payload);
326
348
  }
349
+ // Cross-event rules apply once the stream has a beginning: the connect snapshot.
350
+ if (seeded)
351
+ violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
327
352
  events.push(envelope);
328
353
  for (const waiter of waiters)
329
354
  waiter(envelope);
@@ -336,8 +361,26 @@ export async function exerciseAdapter(adapter, context, options = {}) {
336
361
  breaks.seed(snapshot);
337
362
  seeded = true;
338
363
  requireCapabilityMethods(live, current().capabilities);
339
- if (publishesUserIds(snapshot))
364
+ if (publishesUserIds(snapshot)) {
340
365
  requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
366
+ // Required by presence is not enough: the names the snapshot published are looked up, and what
367
+ // comes back is held to the shape -- an empty answer to a roster of colleagues is named.
368
+ const named = userIdsIn(snapshot);
369
+ if (typeof live.describeUsers === "function" && named.length > 0) {
370
+ let described;
371
+ try {
372
+ described = await live.describeUsers(named);
373
+ violations.push(...validateDescribedUsers(described, named, "connection.describeUsers"));
374
+ if (Array.isArray(described) && described.length === 0) {
375
+ violations.push({ rule: "connection.describeUsers.empty", path: "connection.describeUsers",
376
+ message: `asked about ${named.join(", ")}, whom the snapshot itself named, the provider described nobody` });
377
+ }
378
+ }
379
+ catch (error) {
380
+ violations.push({ rule: "connection.describeUsers.rejected", path: "connection.describeUsers", message: `describeUsers rejected rather than answered: ${String(error)}` });
381
+ }
382
+ }
383
+ }
341
384
  // Capacity is stated, not requested: nothing may be allocated until it is, so a connection
342
385
  // that will not accept one is a connection nothing can be given to.
343
386
  // The guide's obligation on a voice adapter: consult the host before declaring the agent
@@ -349,7 +392,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
349
392
  message: "a voice adapter consults the host's report before declaring the agent ready, and this one never asked",
350
393
  });
351
394
  }
352
- const capacity = await connection.setCapacity({ count: 1 });
395
+ capacityStated = 1;
396
+ const capacity = await connection.setCapacity({ count: capacityStated });
353
397
  const malformed = validateResult(capacity, "setCapacity", "connection.setCapacity");
354
398
  violations.push(...malformed);
355
399
  // The provider republishes the agent's day: once connected, the identity carries the zone the
@@ -425,6 +469,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
425
469
  }
426
470
  if (!options.collectOnly)
427
471
  assertNoViolations(violations);
472
+ stopObserving();
428
473
  return {
429
474
  events: events,
430
475
  authenticationState: authenticationState,
@@ -432,6 +477,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
432
477
  notExercised: CONTRACT_SUBJECTS.filter(subject => !seen.has(subject)),
433
478
  disconnectWasClean,
434
479
  violations,
480
+ rulesEvaluated,
435
481
  };
436
482
  }
437
483
  /**
@@ -448,6 +494,42 @@ function publishesUserIds(snapshot) {
448
494
  return false;
449
495
  return snapshot.tasks.some(taskNamesUsers);
450
496
  }
497
+ /** Every UserId a snapshot publishes: on the record, the room, a lead request, an imposed break, the roster. */
498
+ function userIdsIn(snapshot) {
499
+ const ids = new Set();
500
+ const add = (value) => { if (typeof value === "string" && value.length > 0)
501
+ ids.add(value); };
502
+ add(snapshot?.break?.imposed?.by);
503
+ const team = snapshot?.team;
504
+ if (isRecord(team)) {
505
+ for (const member of Array.isArray(team.members) ? team.members : [])
506
+ if (isRecord(member))
507
+ add(member.id);
508
+ for (const request of Array.isArray(team.requests) ? team.requests : [])
509
+ if (isRecord(request))
510
+ add(request.memberId);
511
+ }
512
+ for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
513
+ const t = task;
514
+ if (isRecord(t.handlingHistory) && Array.isArray(t.handlingHistory.steps))
515
+ for (const step of t.handlingHistory.steps)
516
+ if (isRecord(step))
517
+ add(step.by);
518
+ if (Array.isArray(t.onCall))
519
+ for (const entry of t.onCall)
520
+ if (isRecord(entry))
521
+ add(entry.userId);
522
+ if (isRecord(t.leadAssist))
523
+ add(t.leadAssist.leadId);
524
+ if (isRecord(t.assisting))
525
+ add(t.assisting.memberId);
526
+ if (isRecord(t.monitoring)) {
527
+ add(t.monitoring.memberId);
528
+ add(t.monitoring.agentId);
529
+ }
530
+ }
531
+ return [...ids];
532
+ }
451
533
  const teamNamesUsers = (team) => isRecord(team) && (some(team.members) || some(team.requests));
452
534
  const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.handlingHistory) && Array.isArray(task.handlingHistory.steps) && task.handlingHistory.steps.some(step => isRecord(step) && step.by !== undefined)) ||
453
535
  (isRecord(task.leadAssist) && task.leadAssist.leadId !== undefined) ||
@@ -720,7 +802,19 @@ export function assertDeniedAndRetriedBreak(approvals) {
720
802
  // completing or ending, never a phase the audio decided.
721
803
  // ---------------------------------------------------------------------------
722
804
  const WORK_BEGUN = new Set(["in-progress", "paused", "completing"]);
805
+ /** 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. */
806
+ const AT_WORK = new Set(["in-progress", "paused"]);
723
807
  /** What a stream has said about the tasks it carries, and the rules across events. */
808
+ const TASK_PHASES_ORDERED = ["pending", "confirmed", "preview", "in-progress", "paused", "completing"];
809
+ /** Where a task may be next, by the guide's transition table, closed over the phases between two publications. */
810
+ const REACHABLE_PHASES = {
811
+ pending: new Set(["pending", "confirmed", "preview", "in-progress", "paused", "completing"]),
812
+ confirmed: new Set(["confirmed", "preview", "in-progress", "paused", "completing"]),
813
+ preview: new Set(["preview", "in-progress", "paused", "completing"]),
814
+ "in-progress": new Set(["in-progress", "paused", "completing"]),
815
+ paused: new Set(["paused", "in-progress", "completing"]),
816
+ completing: new Set(["completing"]),
817
+ };
724
818
  export class TaskStream {
725
819
  tasks = new Map();
726
820
  // Every dial the stream can place an outcome against: one the host said it placed, or one a
@@ -744,6 +838,19 @@ export class TaskStream {
744
838
  this.dialled(entry.dialId);
745
839
  }
746
840
  }
841
+ /** How many tasks the stream currently holds open. */
842
+ openCount() { return this.tasks.size; }
843
+ /** 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. */
844
+ static carriesDial(task) {
845
+ if (!isRecord(task) || !Array.isArray(task.onCall))
846
+ return false;
847
+ return task.onCall.some(entry => isRecord(entry) && typeof entry.dialId === "string");
848
+ }
849
+ /** 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. */
850
+ static partyDialled(task) {
851
+ return isRecord(task) && Array.isArray(task.onCall)
852
+ && task.onCall.some(entry => isRecord(entry) && entry.role === "party" && typeof entry.stage === "string");
853
+ }
747
854
  /** The entries of a task's record, each by step and instant, or undefined where the task carries no record. */
748
855
  static record(task) {
749
856
  if (!isRecord(task) || !isRecord(task.handlingHistory) || !Array.isArray(task.handlingHistory.steps))
@@ -794,6 +901,7 @@ export class TaskStream {
794
901
  const known = id === undefined ? undefined : this.tasks.get(id);
795
902
  switch (event.type) {
796
903
  case "snapshot":
904
+ ruleEvaluated("stream.snapshot.capabilitySource", "stream.snapshot.handlingHistory");
797
905
  // A snapshot replaces what is known, and still may not say a task lost terms it had read.
798
906
  if (isRecord(event.snapshot) && Array.isArray(event.snapshot.tasks)) {
799
907
  event.snapshot.tasks.forEach((task, index) => {
@@ -813,6 +921,7 @@ export class TaskStream {
813
921
  this.seed(event.snapshot);
814
922
  break;
815
923
  case "task-offered":
924
+ ruleEvaluated("stream.taskOffered.duplicate");
816
925
  if (id === undefined)
817
926
  break;
818
927
  if (known !== undefined)
@@ -823,6 +932,7 @@ export class TaskStream {
823
932
  case "task-updated":
824
933
  if (id === undefined)
825
934
  break;
935
+ ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
826
936
  if (known === undefined) {
827
937
  refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
828
938
  break;
@@ -833,6 +943,22 @@ export class TaskStream {
833
943
  if ((known.source === "queue" || known.source === "ungoverned") && isRecord(event.task) && event.task.capabilitySource === "undetermined") {
834
944
  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
945
  }
946
+ // A task does not go backwards. The stream sees publications, not transitions, and a task may
947
+ // pass through a phase between two, so what is refused is a phase unreachable from the last
948
+ // one read by the guide's table: back to pending, back to confirmed once work began, out of
949
+ // completing except through a connect-back -- which the update itself shows, the party
950
+ // carrying the host's dial.
951
+ {
952
+ const from = known.phase;
953
+ const to = isRecord(event.task) ? String(event.task.phase) : "";
954
+ const reachable = REACHABLE_PHASES[from];
955
+ if (reachable !== undefined && TASK_PHASES_ORDERED.includes(to) && !reachable.has(to)) {
956
+ const connectingBack = from === "completing" && TaskStream.partyDialled(event.task) && (to === "in-progress" || to === "paused");
957
+ if (!connectingBack) {
958
+ 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}`}`);
959
+ }
960
+ }
961
+ }
836
962
  // A record once read is not unread: an update restates it whole, or with more, never with less.
837
963
  {
838
964
  const lost = TaskStream.lost(known.record, TaskStream.record(event.task));
@@ -867,13 +993,14 @@ export class TaskStream {
867
993
  this.noteDials(event.task);
868
994
  break;
869
995
  case "task-media-started":
996
+ ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate");
870
997
  if (id === undefined)
871
998
  break;
872
999
  if (known === undefined) {
873
1000
  refuse("stream.taskMediaStarted.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
874
1001
  break;
875
1002
  }
876
- if (!WORK_BEGUN.has(known.phase)) {
1003
+ if (!AT_WORK.has(known.phase)) {
877
1004
  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
1005
  }
879
1006
  if (known.media === "started") {
@@ -882,6 +1009,7 @@ export class TaskStream {
882
1009
  known.media = "started";
883
1010
  break;
884
1011
  case "task-media-ended":
1012
+ ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent");
885
1013
  if (id === undefined)
886
1014
  break;
887
1015
  if (known === undefined) {
@@ -977,6 +1105,15 @@ async function driveOneCall(drive) {
977
1105
  return;
978
1106
  }
979
1107
  session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
1108
+ // The reload is a restore before it is anything else: the second session stands authenticated
1109
+ // as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
1110
+ const restored = await session.state();
1111
+ found.push(...validateAuthenticationState(restored, "drive.reload.login"));
1112
+ const same = isRecord(restored) && restored.status === "authenticated" && isRecord(restored.identity) && restored.identity.id === drive.reader().self;
1113
+ if (!same) {
1114
+ 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)}`);
1115
+ return;
1116
+ }
980
1117
  second = await again.connect(drive.context);
981
1118
  const snapshot = await second.snapshot();
982
1119
  // The second adapter's snapshot is held to everything a first one is.
@@ -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. */
@@ -131,14 +131,32 @@ const isDurationSeconds = (value) => typeof value === "number" && Number.isInteg
131
131
  /** Opaque, non-empty, provider-issued. Never parsed and never compared across providers. */
132
132
  const isUserId = isFilled;
133
133
  const isTaskId = isFilled;
134
+ const ruleListeners = new Set();
135
+ /**
136
+ * Hears every rule a validator evaluates, pass or fail, until the returned function is called. A
137
+ * green run can then say which rules it looked at rather than only which subjects it reached: a
138
+ * rule never evaluated is a visible gap, not a pass. The harness registers one for each exercise.
139
+ */
140
+ export function observeRules(listener) {
141
+ ruleListeners.add(listener);
142
+ return () => { ruleListeners.delete(listener); };
143
+ }
144
+ /** Says a rule was evaluated, for code that decides outside a collector: the stream, the drive. */
145
+ export function ruleEvaluated(...rules) {
146
+ for (const rule of rules)
147
+ for (const listener of ruleListeners)
148
+ listener(rule);
149
+ }
134
150
  class Collector {
135
151
  violations = [];
136
152
  add(rule, path, message) {
153
+ ruleEvaluated(rule);
137
154
  this.violations.push({ rule, path, message });
138
155
  }
139
156
  require(condition, rule, path, message) {
157
+ ruleEvaluated(rule);
140
158
  if (!condition)
141
- this.add(rule, path, message);
159
+ this.violations.push({ rule, path, message });
142
160
  return Boolean(condition);
143
161
  }
144
162
  filled(value, rule, path, message) {
@@ -198,7 +216,7 @@ export function validateScheduledActivity(activity, path = "scheduledActivity")
198
216
  validateScheduledActivityInto(activity, path, into);
199
217
  return into.violations;
200
218
  }
201
- function validateScheduledActivityInto(activity, path, into) {
219
+ function validateScheduledActivityInto(activity, path, into, levels) {
202
220
  if (!isPlainObject(activity)) {
203
221
  into.add("activity.shape", path, "a scheduled activity must be an object");
204
222
  return;
@@ -213,7 +231,7 @@ function validateScheduledActivityInto(activity, path, into) {
213
231
  }
214
232
  }
215
233
  if (activity.party !== undefined)
216
- validateContactInto(activity.party, `${path}.party`, into);
234
+ validateContactInto(activity.party, `${path}.party`, into, levels);
217
235
  validateAttributes(activity.attributes, `${path}.attributes`, into);
218
236
  }
219
237
  // ---------------------------------------------------------------------------
@@ -672,7 +690,7 @@ function validateBrowsers(value, path, into) {
672
690
  }
673
691
  });
674
692
  }
675
- function validateTaskAttributes(value, path, into) {
693
+ function validateTaskAttributes(value, path, into, levels) {
676
694
  if (value === undefined)
677
695
  return;
678
696
  if (!Array.isArray(value)) {
@@ -699,7 +717,7 @@ function validateTaskAttributes(value, path, into) {
699
717
  into.require(typeof attribute.value === "string", "task.attribute.text", `${at}.value`, "a text attribute needs a string value");
700
718
  break;
701
719
  case "contact":
702
- validateContactInto(attribute.contact, `${at}.contact`, into);
720
+ validateContactInto(attribute.party, `${at}.party`, into, levels);
703
721
  break;
704
722
  case "timestamp":
705
723
  into.timestamp(attribute.at, "task.attribute.timestamp", `${at}.at`);
@@ -791,13 +809,18 @@ function validateHandlingHistory(value, path, into, task = {}) {
791
809
  /** Present only while consulting, and only on voice: elsewhere there is nobody to consult. */
792
810
  const TASK_MEDIA_STATES = membersOf({ started: true, ended: true });
793
811
  /** Real-time media is a voice affair, and its state is one of two words. */
794
- function validateTaskMedia(value, channel, path, into) {
812
+ function validateTaskMedia(value, channel, phase, path, into) {
795
813
  if (value === undefined)
796
814
  return;
797
815
  if (!into.require(channel === "voice", "task.media.channel", path, `a ${channel} task carries no real-time media state`))
798
816
  return;
799
- into.oneOf(value, TASK_MEDIA_STATES, "task.media", path);
817
+ if (into.oneOf(value, TASK_MEDIA_STATES, "task.media", path)) {
818
+ // Nothing is acquired while pending, and a preview has placed no call: media names a task whose
819
+ // work has begun, on a snapshot as on the event, or a host would open the microphone on an offer.
820
+ into.require(!WORK_NOT_BEGUN.includes(phase), "task.media.beforeWork", path, `a ${String(phase)} task has no media: audio arrives once its work has begun`);
821
+ }
800
822
  }
823
+ const WORK_NOT_BEGUN = ["pending", "confirmed", "preview"];
801
824
  /**
802
825
  * Who is on the call, as the provider states it. Voice only. Each entry is a role with what that
803
826
  * role needs and nothing another role would: a party or an agent dialled nowhere, so neither
@@ -845,9 +868,23 @@ function validateOnCall(value, channel, path, into) {
845
868
  if (entry.label !== undefined)
846
869
  into.filled(entry.label, "task.onCall.label", `${at}.label`, "a label must not be empty when present");
847
870
  }
871
+ else if (role === "party" && (entry.dialId !== undefined || entry.stage !== undefined)) {
872
+ // The party is dialled again on the same task -- a connect-back the host placed, a callback the
873
+ // platform placed -- and then carries the stage the dial has reached; a dial with no stage is
874
+ // half a claim, and the host's dialId is present where a host placed it, as on any dialled entry.
875
+ into.require(entry.stage !== undefined, "task.onCall.party.dial", at, "a party being dialled again carries the stage the dial has reached; the host's dialId beside it where a host placed the dial");
876
+ if (entry.dialId !== undefined)
877
+ into.filled(entry.dialId, "task.onCall.dialId", `${at}.dialId`, "dialId must not be empty when present");
878
+ if (entry.stage !== undefined && into.oneOf(entry.stage, ON_CALL_STAGES, "task.onCall.stage", `${at}.stage`)) {
879
+ into.require(!(entry.stage === "ringing" && entry.held === true), "task.onCall.held.ringing", `${at}.held`, "nobody ringing is held; held belongs to somebody who has joined");
880
+ }
881
+ for (const field of ["destinationId", "label"]) {
882
+ into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a party is not a directory item; ${field} belongs on consulted or conferenced`);
883
+ }
884
+ }
848
885
  else {
849
886
  for (const field of ["destinationId", "dialId", "label", "stage"]) {
850
- into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, `a ${role} was dialled from nowhere; ${field} belongs on consulted or conferenced`);
887
+ into.require(entry[field] === undefined, `task.onCall.${field}.unexpected`, `${at}.${field}`, role === "party" ? `a party carries ${field} on a connect-back alone, with its dial and stage together` : `a ${role} was dialled from nowhere; ${field} belongs on consulted or conferenced`);
851
888
  }
852
889
  }
853
890
  });
@@ -975,7 +1012,7 @@ function validateTaskInto(task, context, path, into) {
975
1012
  if (task.party !== undefined)
976
1013
  validateContactInto(task.party, `${path}.party`, into, context.levels);
977
1014
  validateBrowsers(task.browsers, `${path}.browsers`, into);
978
- validateTaskAttributes(task.attributes, `${path}.attributes`, into);
1015
+ validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
979
1016
  validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into, { phase: task.phase, media: task.media });
980
1017
  validateOnCall(task.onCall, context.channel, `${path}.onCall`, into);
981
1018
  // The room is who is on the call now, and a task outlives its call by the whole of wrap-up: a
@@ -984,7 +1021,7 @@ function validateTaskInto(task, context, path, into) {
984
1021
  if (Array.isArray(task.onCall) && task.onCall.length > 0 && (task.phase === "completing" || task.media === "ended")) {
985
1022
  into.add("task.onCall.ended", `${path}.onCall`, `the call has ended (${task.phase === "completing" ? "the task is completing" : "its media ended"}) and onCall still names people on it: the room is who is on the call now, and now nobody is`);
986
1023
  }
987
- validateTaskMedia(task.media, context.channel, `${path}.media`, into);
1024
+ validateTaskMedia(task.media, context.channel, task.phase, `${path}.media`, into);
988
1025
  validateLeadAssist(task.leadAssist, context.channel, `${path}.leadAssist`, into);
989
1026
  validateAssisting(task.assisting, context.channel, `${path}.assisting`, into);
990
1027
  validateMonitoring(task.monitoring, context.channel, `${path}.monitoring`, into);
@@ -1320,7 +1357,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1320
1357
  if (snapshot.contacts !== undefined) {
1321
1358
  into.require(idle.contacts === true, "snapshot.contacts.capability", `${path}.contacts`, "contacts require the contacts idle capability");
1322
1359
  if (Array.isArray(snapshot.contacts)) {
1323
- snapshot.contacts.forEach((contact, index) => validateContactInto(contact, `${path}.contacts[${index}]`, into));
1360
+ snapshot.contacts.forEach((contact, index) => validateContactInto(contact, `${path}.contacts[${index}]`, into, levels));
1324
1361
  }
1325
1362
  else {
1326
1363
  into.add("snapshot.contacts.shape", `${path}.contacts`, "contacts must be an array when present");
@@ -1331,7 +1368,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1331
1368
  if (Array.isArray(snapshot.scheduledActivities)) {
1332
1369
  const seen = new Set();
1333
1370
  snapshot.scheduledActivities.forEach((activity, index) => {
1334
- validateScheduledActivityInto(activity, `${path}.scheduledActivities[${index}]`, into);
1371
+ validateScheduledActivityInto(activity, `${path}.scheduledActivities[${index}]`, into, levels);
1335
1372
  if (isPlainObject(activity) && isFilled(activity.id)) {
1336
1373
  if (seen.has(activity.id)) {
1337
1374
  into.add("activity.id.unique", `${path}.scheduledActivities[${index}].id`, `duplicate activity id: ${activity.id}`);
@@ -1535,7 +1572,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1535
1572
  into.add("event.contacts.shape", `${at}.contacts`, "contacts must be an array");
1536
1573
  }
1537
1574
  else {
1538
- event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into));
1575
+ event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into, levels));
1539
1576
  }
1540
1577
  break;
1541
1578
  case "calendar-updated":
@@ -1546,7 +1583,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1546
1583
  else {
1547
1584
  const ids = new Set();
1548
1585
  event.scheduledActivities.forEach((activity, index) => {
1549
- validateScheduledActivityInto(activity, `${at}.scheduledActivities[${index}]`, into);
1586
+ validateScheduledActivityInto(activity, `${at}.scheduledActivities[${index}]`, into, levels);
1550
1587
  if (isPlainObject(activity) && isFilled(activity.id)) {
1551
1588
  if (ids.has(activity.id))
1552
1589
  into.add("activity.id.unique", `${at}.scheduledActivities[${index}].id`, `duplicate activity id: ${activity.id}`);
@@ -1826,6 +1863,22 @@ export function validateTaskCommand(command, task, path = "command") {
1826
1863
  : [...new Set([...TASK_COMMAND_NAMES.voice, ...TASK_COMMAND_NAMES.chat, ...TASK_COMMAND_NAMES.email])];
1827
1864
  if (type === "custom") {
1828
1865
  into.filled(command.name, "command.custom.name", `${path}.name`, "a custom command names the control it presses");
1866
+ if (task === undefined)
1867
+ return into.violations;
1868
+ // Held to the task: the control it presses is one the task published, and what the control asked for travels with it.
1869
+ const custom = isPlainObject(task) && isPlainObject(task.capabilities) ? task.capabilities.custom : undefined;
1870
+ const control = Array.isArray(custom) ? custom.filter(isPlainObject).find(entry => entry.id === command.name) : undefined;
1871
+ if (control === undefined) {
1872
+ into.add("command.capability.custom", path, `custom ${String(command.name)} presses a control the task never published`);
1873
+ return into.violations;
1874
+ }
1875
+ if (isPlainObject(control.prompt) && Array.isArray(control.prompt.fields)) {
1876
+ for (const field of control.prompt.fields.filter(isPlainObject)) {
1877
+ if (typeof field.name === "string") {
1878
+ into.require(typeof command[field.name] === "string" && command[field.name].length > 0, "command.custom.prompt", `${path}.${field.name}`, `the control asked for ${field.name} and the command does not carry it`);
1879
+ }
1880
+ }
1881
+ }
1829
1882
  return into.violations;
1830
1883
  }
1831
1884
  if (!into.require(typeof type === "string" && names.includes(type), "command.type", `${path}.type`, channel === undefined ? `unsupported command: ${String(type)}` : `a ${channel} task has no ${String(type)} command`)) {
@@ -1911,12 +1964,17 @@ export function validateTaskCommand(command, task, path = "command") {
1911
1964
  // to act on yet, and in completing the handling has ended: a call with nobody on it, a
1912
1965
  // conversation closed. What a capability offers, the phase decides whether there is anything to use it on.
1913
1966
  const handling = () => inPhase("command.phase.handling", "in-progress", "paused");
1967
+ // A destination is one the directory offered: the id Omni sends is the id the provider published.
1968
+ const listed = (name) => {
1969
+ const declared = capabilities[name];
1970
+ const directory = isPlainObject(declared) && Array.isArray(declared.destinations) ? declared.destinations.filter(isPlainObject) : [];
1971
+ into.require(directory.some(item => item.id === command.destinationId), "command.destination.unknown", `${path}.destinationId`, `${String(command.destinationId)} is not a destination the task's ${name} directory offered`);
1972
+ };
1914
1973
  const onCall = Array.isArray(task.onCall) ? task.onCall.filter(isPlainObject) : [];
1915
1974
  switch (type) {
1916
1975
  case "answer":
1917
1976
  case "accept":
1918
1977
  case "decline":
1919
- case "reject":
1920
1978
  inPhase("command.phase.pending", "pending");
1921
1979
  if (type === "decline")
1922
1980
  offered("decline");
@@ -1944,10 +2002,14 @@ export function validateTaskCommand(command, task, path = "command") {
1944
2002
  break;
1945
2003
  case "transfer":
1946
2004
  handling();
1947
- if (command.action === "cold")
1948
- offered("coldTransfer");
1949
- else if (command.action === "warm")
1950
- offered("warmTransfer");
2005
+ if (command.action === "cold") {
2006
+ if (offered("coldTransfer"))
2007
+ listed("coldTransfer");
2008
+ }
2009
+ else if (command.action === "warm") {
2010
+ if (offered("warmTransfer"))
2011
+ listed("warmTransfer");
2012
+ }
1951
2013
  else if (command.action === "complete" || command.action === "cancel") {
1952
2014
  into.require(onCall.some(entry => entry.role === "consulted"), "command.transfer.consulted", path, `${String(command.action)} needs a consulted entry on onCall: without one there is nothing to complete or cancel`);
1953
2015
  }
@@ -1964,9 +2026,12 @@ export function validateTaskCommand(command, task, path = "command") {
1964
2026
  into.require(task.assisting !== undefined, "command.leadAssist.assisting", path, `${String(command.action)} is the lead's own act on a call they joined: the task carries assisting`);
1965
2027
  }
1966
2028
  break;
1967
- case "conference":
2029
+ case "conference": {
1968
2030
  handling();
1969
- if (offered("conference") && command.action === "remove") {
2031
+ const conferencing = offered("conference");
2032
+ if (conferencing && command.action === "add")
2033
+ listed("conference");
2034
+ if (conferencing && command.action === "remove") {
1970
2035
  const others = onCall.filter(entry => entry.role !== "agent");
1971
2036
  if (command.party === true) {
1972
2037
  into.require(others.some(entry => entry.role !== "party"), "command.conference.remove.alone", path, "removing the party would leave the agent alone: that is end-call");
@@ -1977,9 +2042,32 @@ export function validateTaskCommand(command, task, path = "command") {
1977
2042
  }
1978
2043
  }
1979
2044
  break;
1980
- case "complete":
2045
+ }
2046
+ case "complete": {
1981
2047
  into.require(task.completionMode === "agent-command", "command.complete.mode", path, "complete belongs to agent-command; a provider-automatic task completes itself");
2048
+ // What travels with complete is what the dispositions capability published: a code from its
2049
+ // list where it has one, a code at all where it requires one, notes as it said, nothing where it published nothing.
2050
+ const dispositions = capabilities.dispositions;
2051
+ if (dispositions === undefined) {
2052
+ into.require(command.disposition === undefined, "command.complete.disposition.unexpected", `${path}.disposition`, "the task declares no dispositions; complete travels with no code");
2053
+ into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task declares no dispositions; complete travels with no notes");
2054
+ }
2055
+ else if (isPlainObject(dispositions)) {
2056
+ if (Array.isArray(dispositions.codes) && command.disposition !== undefined) {
2057
+ into.require(dispositions.codes.some(code => isPlainObject(code) && code.id === command.disposition), "command.complete.disposition.unknown", `${path}.disposition`, `${String(command.disposition)} is not a code the task published`);
2058
+ }
2059
+ if (dispositions.required === true) {
2060
+ into.require(command.disposition !== undefined, "command.complete.disposition.required", `${path}.disposition`, "the task requires a disposition and complete carries none");
2061
+ }
2062
+ if (dispositions.notes === "none") {
2063
+ into.require(command.notes === undefined, "command.complete.notes.unexpected", `${path}.notes`, "the task takes no notes");
2064
+ }
2065
+ else if (dispositions.notes === "required") {
2066
+ into.require(typeof command.notes === "string" && command.notes.length > 0, "command.complete.notes.required", `${path}.notes`, "the task requires notes and complete carries none");
2067
+ }
2068
+ }
1982
2069
  break;
2070
+ }
1983
2071
  default:
1984
2072
  break;
1985
2073
  }
@@ -2076,6 +2164,26 @@ export function validateTimeZone(value, path = "context.timeZone") {
2076
2164
  into.require(isTimeZone(value), "context.timeZone", path, "a host states the agent's time zone at connect as an IANA name, such as Asia/Kolkata; an offset cannot survive a daylight-saving boundary");
2077
2165
  return into.violations;
2078
2166
  }
2167
+ /**
2168
+ * What `describeUsers(ids)` answered: an array of users, each one asked for, each a valid identity.
2169
+ * The provider may answer fewer than asked, which is what "unknown to it" looks like; it never
2170
+ * answers somebody nobody asked about.
2171
+ */
2172
+ export function validateDescribedUsers(users, asked, path = "describeUsers") {
2173
+ const into = new Collector();
2174
+ if (!Array.isArray(users)) {
2175
+ into.add("describeUsers.shape", path, "describeUsers answers an array of users, empty when it knows none of them");
2176
+ return into.violations;
2177
+ }
2178
+ users.forEach((user, index) => {
2179
+ const at = `${path}[${index}]`;
2180
+ validateUser(user, "describeUsers.user", at, into);
2181
+ if (isPlainObject(user) && typeof user.id === "string") {
2182
+ into.require(asked.includes(user.id), "describeUsers.unasked", `${at}.id`, `${user.id} was not among the ids asked for`);
2183
+ }
2184
+ });
2185
+ return into.violations;
2186
+ }
2079
2187
  function validateUser(value, rule, path, into) {
2080
2188
  if (!isPlainObject(value)) {
2081
2189
  into.add(rule, path, "an identity must be an object");
package/guide.md CHANGED
@@ -314,7 +314,7 @@ type LoginStore = {
314
314
  type ConnectContext = {
315
315
  protocolVersion: number;
316
316
  loginId: string;
317
- autoAcceptTasks?: boolean;
317
+ autoAcceptTasks: boolean;
318
318
  timeZone: TimeZone;
319
319
  store: LoginStore;
320
320
  phone?: Phone;
@@ -621,7 +621,8 @@ type OnCallRole = "party" | "agent" | "consulted" | "conferenced";
621
621
  type OnCallStage = "ringing" | "joined";
622
622
 
623
623
  type OnCall = { since: IsoTimestamp; held?: true } & (
624
- | { role: "party" }
624
+ | { role: "party"; dialId?: never; stage?: never }
625
+ | { role: "party"; stage: OnCallStage; dialId?: DialId }
625
626
  | { role: "agent"; userId: UserId }
626
627
  | { role: "consulted" | "conferenced"; destinationId: string; stage: OnCallStage; dialId?: DialId; label?: string }
627
628
  );
@@ -1943,7 +1944,7 @@ Creates one live provider connection for the signed-in agent.
1943
1944
  | --- | --- |
1944
1945
  | `protocolVersion` | Version negotiated before authentication. Fixed for this login. |
1945
1946
  | `loginId` | Omni-generated identity for this login. It is the same value passed as `AuthenticationContext.loginId`, so an adapter can correlate this connection with the session that authenticated it. Stable across transport reconnects and changed only by a new login. |
1946
- | `autoAcceptTasks` | Agent provisioning policy relayed to the provider at login. Treated as `true` when omitted. When `true`, a pending task states its `acceptance`; when `false`, every task requires agent acceptance. Fixed for this connection, like everything else here: the provider states or omits `acceptance` by the value it was sent, and Omni validates by that same value, not by a policy that has since moved — a change reaches the provider through a fresh `connect()`. |
1947
+ | `autoAcceptTasks` | Agent provisioning policy relayed to the provider at login, stated by the host on every connection and never assumed from its absence. When `true`, a pending task states its `acceptance`; when `false`, every task requires agent acceptance. Fixed for this connection, like everything else here: the provider states or omits `acceptance` by the value it was sent, and Omni validates by that same value, not by a policy that has since moved — a change reaches the provider through a fresh `connect()`. |
1947
1948
  | `timeZone` | The same value passed as `AuthenticationContext.timeZone`. The provider stores it on the agent and carries it on the identity. See **The agent's day**. |
1948
1949
  | `phone` | The same value passed as `AuthenticationContext.phone`: how this login hears its calls. The type ties `host.mute` to it: a `softphone` login's host states what its Mute does, and a desk-phone or conversation login's host cannot, so the omission is a compile error rather than a live seat's discovery. See **The station is the host's**. |
1949
1950
  | `store` | The login's operational store, kept by the host for the life of the login, across a reload of the host, and cleared at sign-out: where an adapter that composes a record keeps what its platform cannot hold for it, such as the handling legs a host reported. Three functions, by key. Never for anything sensitive, which is `AuthenticationContext.secrets`, a store a host may clear aggressively. **A task's keys carry the task id and go with the task**: a key written about a task names the task's id in the key, and is deleted before the task's end is published, because a platform retires a task id minutes after closing it and a requeue takes seconds, so a key that outlives its task is inherited by the next offer of the same id -- a record with legs the host never reported against it. A login-scoped key carries no task id and outlives any task. The harness requires the store of every connection (`store.shape`, `store.get`, `.set`, `.delete`), watches the one it hands over, and names a task's key still held after `task-ended` (`drive.store.retained`) or written about the task after its end -- a persist hung off a timer that saw the task as it was (`drive.store.late`). The ordinary late write is the host's, not the adapter's: a host reports a leg without waiting for the answer, so an unmute can follow `complete` by a tick, and the adapter answers a report about a task that has ended `failed` with `omni.task-not-found` and writes nothing -- the host ends its own open legs at the task's end, so such a report is the host's error to see, and a host reads every `recordStep` answer and awaits the one for the leg it closes at a task's end, the only moment a refusal is expected, since a report nobody waits for is an error nobody can see; an adapter that names no task in its keys gets no cleanup check, which is a gap rather than a pass, never an exemption: the obligation is that nothing of a closed task survives its ending, and an adapter that keeps every open task in one login-scoped value owes exactly that inside the value, where the harness cannot look. One key per task, named for it, is the shape the harness can hold, and the shape to reach for. The store lists nothing, so an adapter that needs to find its tasks keeps a login-scoped index of ids beside them; at the task's end it deletes the body first and reindexes after, since a crash between the two then leaves an index naming a task with no body, which a reader skips, where the other order leaves a body for a task that has ended, which is the hazard itself. A reader of the index tolerates an id with no body as an ending that was underway, not as corruption. |
@@ -2144,12 +2145,11 @@ provider's requirement, stated on a wire where Omni was willing to accept for th
2144
2145
  no-auto-accept policy puts no word on the wire at all — the field is absent, and the **Accept**
2145
2146
  press is Omni's doing, not the provider's.
2146
2147
 
2147
- **An absent value means `true`**, as `readyOnLogin` does, because an agent who has signed in and
2148
- gone ready is telling the deployment they are working. Requiring a press before every contact is
2149
- the exception a provisioning file asks for, not the state it falls into when a flag is missing.
2150
-
2151
- Nothing is given away by that default. `acceptance` is the provider's own control and outranks
2152
- it: `consent` puts the decision back in the agent's hands for any task where it
2148
+ **The value is stated, never assumed.** `autoAcceptTasks` is required of every connection: a
2149
+ host says which policy the deployment provisioned, and a validator that is not told checks
2150
+ neither rule rather than guess the permissive one (`task.acceptance.required` and
2151
+ `.unexpected` fire only against a stated value). `acceptance` is the provider's own control and
2152
+ outranks the policy: `consent` puts the decision back in the agent's hands for any task where it
2153
2153
  belongs, whatever the host was configured with.
2154
2154
 
2155
2155
  An automatically accepted task still arrives through `task-offered`.
@@ -2230,7 +2230,7 @@ time. Runtime conformance checks also require the task channel to match its prov
2230
2230
  | `browsers` | Named browser definitions for the task workspace: at least one when the task declares the `browsers` capability, empty when it does not. |
2231
2231
  | `party` | The person or entity on the other end of this task, as a `Contact`: often a name and one address; a withheld caller ID may leave nothing to send at all. Optional. The party is who the task is *with*; `contacts` is the directory. |
2232
2232
  | `phase` | Current canonical task phase: `pending`, `confirmed`, `preview`, `in-progress`, `paused`, or `completing`. `preview` is voice only. |
2233
- | `media` | Voice only. The task's real-time audio as the provider holds it: `started` while audio is attached, `ended` once it ended, omitted while none is. The provider's word — see **`task-media-started`**. |
2233
+ | `media` | Voice only. The task's real-time audio as the provider holds it: `started` while audio is attached, `ended` once it ended, omitted while none is. The provider's word — see **`task-media-started`**. Media names a task whose work has begun: on a `pending`, `confirmed` or `preview` task it is refused (`task.media.beforeWork`), on a snapshot as on the event, since a host opens the microphone on it. |
2234
2234
  | `acceptance` | How this offer is accepted — `no-preference`, `consent`, or `automatic` — stated on the pending task so a reconnect snapshot says it too. Required while `pending` when `autoAcceptTasks` was `true`, forbidden when it was `false`, and absent past `pending`. See **Acceptance modes**. |
2235
2235
  | `previewEndsAt` | Voice only, in `preview`: when the system stops waiting for the agent to press Call. Absent, the agent has as long as they need. Always with `atDeadline`. See **Preview: the agent presses Call**. |
2236
2236
  | `atDeadline` | Voice only, in `preview`, with `previewEndsAt`: what the system does at the deadline -- `calls` places the call itself, `expires` takes the record back and the task ends `expired`. |
@@ -2239,7 +2239,7 @@ time. Runtime conformance checks also require the task channel to match its prov
2239
2239
  | `wrapAllowance` | Fixed time allowed to complete the task after primary handling ends. For real-time media, it begins after `task-media-ended`. Required under `provider-automatic`, where the provider acts on it. Optional under `agent-command`: omitted says the provider imposes no deadline, and Omni counts nothing down. |
2240
2240
  | `attributes` | Optional ordered, typed `TaskAttribute` entries with keys unique within the task. Each contact or timestamp is a separate array item; new attribute shapes require new union members. |
2241
2241
  | `handlingHistory` | The call record: `steps` — the ordered handling history of this open task, one entry per occurrence, oldest first — and what they add up to before this agent, `handleSeconds`, `holdSeconds`, `queueSeconds`, `transfers`, each present when the provider knows it. Live task data restated with the task, not a permanent archive. See **How a task has been handled**. |
2242
- | `onCall` | Voice only. Who is on the call, or being brought onto it, as the provider states it, replaced whole with the task: `party` is the customer, `agent` a person by user id, `consulted` and `conferenced` somebody a dial is bringing in, listed from the moment the dial is placed -- with the `destinationId` dialled, the `dialId` where a host placed it, the `stage` reached (`ringing` until answered, `joined` after), and `held: true` on anyone joined and parked. A `consulted` entry is what makes `transfer` `complete` and `cancel` issuable. `label` names a destination -- a person, a queue -- not a phrase; the host supplies the verb. Present when the provider knows the room, absent when it does not. See **Every dial has an outcome**. |
2242
+ | `onCall` | Voice only. Who is on the call, or being brought onto it, as the provider states it, replaced whole with the task: `party` is the customer -- carrying a `stage` while being dialled again on the same task, a connect-back with the host's `dialId` or a platform's callback without, ringing from the moment the dial is placed and joined on its answered outcome --, `agent` a person by user id, `consulted` and `conferenced` somebody a dial is bringing in, listed from the moment the dial is placed -- with the `destinationId` dialled, the `dialId` where a host placed it, the `stage` reached (`ringing` until answered, `joined` after), and `held: true` on anyone joined and parked. A `consulted` entry is what makes `transfer` `complete` and `cancel` issuable. `label` names a destination -- a person, a queue -- not a phrase; the host supplies the verb. Present when the provider knows the room, absent when it does not. See **Every dial has an outcome**. |
2243
2243
  | `leadAssist` | Voice only. Present from the agent's request for a lead until the lead leaves or the request ends: `requested` while nobody has joined, `joined` with the lead's `leadId` once somebody has. See **Lead assist**. |
2244
2244
  | `assisting` | Voice only, on the lead's own task for a call they joined: which member asked, with their note. Its presence is what makes `lead-assist` `take-over` and `leave` issuable. See **Lead assist**. |
2245
2245
  | `monitoring` | Voice only, on the lead's own task while they listen to a member's call: whose call, which call, and the `mode` they are heard in, restated on every change. Never on the member's task, and never together with `assisting`. See **Monitoring a call**. |
@@ -2422,6 +2422,16 @@ whose outcome says so and a call whose media ended: the task returns to `complet
2422
2422
  same event and the clock starts again from there. At no point is an agent dialling against a
2423
2423
  deadline.
2424
2424
 
2425
+ **The room shows the party being dialled again.** From the moment the connect-back is placed the
2426
+ `party` entry carries `stage: "ringing"` and the host's `dialId`, exactly as a consulted or
2427
+ conferenced entry does from its dial, and `joined` on the answered `dial-outcome` and never before
2428
+ it (`stream.taskUpdated.stage`); a host shows a call being placed, not a party it asserts is on a
2429
+ call that is still ringing. A callback the platform places itself on the same task -- the first
2430
+ call over, the platform dialling the party again without a command -- shows the same thing with no
2431
+ host `dialId`, as any platform-placed dial does. A party with no stage is on the call, and a dial
2432
+ with no stage is half a claim (`task.onCall.party.dial`). The stage is also what lets the stream
2433
+ tell a task coming back from one going backwards (`stream.taskUpdated.phase`).
2434
+
2425
2435
  **The control exists only while there is a window to use it in.** Under `agent-command` the task
2426
2436
  stays `completing` until the agent completes it, so the window is open for as long as they need.
2427
2437
  Under `provider-automatic` the window is the allowance -- and with `wrapAllowance: 0` there
@@ -2500,7 +2510,10 @@ entries, one per join. Order is enforced: an entry earlier than the one before i
2500
2510
  total the provider adds: *time to offer*, from the call's arrival to the agent's screen lighting
2501
2511
  up, is the `offered` entry's `at` minus the last `queued` entry's, and the ring is outside it.
2502
2512
  `queueSeconds` keeps the industry's meaning -- the whole wait until somebody answered -- and is not
2503
- that interval.
2513
+ that interval. **The second is the record's grain.** A duration is a positive whole number of
2514
+ seconds, and nought is refused because it claims no time; a leg that happened is stated at the
2515
+ grain: rounded to the nearest second, and a leg that rounds to nought is stated as `1`, the error
2516
+ being under the contract's resolution, where dropping the entry would say the leg never happened.
2504
2517
 
2505
2518
  **A record once read is not unread.** Every restatement of a task's record -- on `task-updated`,
2506
2519
  on a resync snapshot -- carries every entry the host has already read, by step and instant, and
@@ -2795,7 +2808,17 @@ Terms once read stay read: a re-read that fails mid-task is not a new fact about
2795
2808
  last statement stands and the failure is a `diagnostic`, and a task that was published under
2796
2809
  `queue` or `ungoverned` never returns to `undetermined`, on an update (`stream.taskUpdated.capabilitySource`)
2797
2810
  or on a resync snapshot (`stream.snapshot.capabilitySource`); a record once read never loses an
2798
- entry the same two ways (`stream.taskUpdated.handlingHistory`, `stream.snapshot.handlingHistory`).
2811
+ entry the same two ways (`stream.taskUpdated.handlingHistory`, `stream.snapshot.handlingHistory`);
2812
+ and a task does not go backwards (`stream.taskUpdated.phase`): the stream sees publications, not
2813
+ transitions, and a task may pass through a phase between two, so `pending` to `in-progress` stands
2814
+ with `confirmed` between them, and what is refused is a phase unreachable from the last one read by
2815
+ the transition table -- back to `pending`, back to `confirmed` or `preview` once work began, out of
2816
+ `completing` except by the party being dialled again, a connect-back or a platform's callback,
2817
+ which the update itself shows: the party on the call carrying a `stage`. A completing task
2818
+ republished as `in-progress` from a stale copy carries no such stage, and that is the ending the
2819
+ agent never saw. Audio arrives only on a task at work: `task-media-started` on a `completing` task
2820
+ is refused as it is on a pending one (`stream.taskMediaStarted.beforeWork`), since a connect-back
2821
+ returns the task to `in-progress` before any media.
2799
2822
 
2800
2823
  What the agent is told differs by source, and only one source tells them anything. Under `queue`
2801
2824
  and `ungoverned` the agent sees controls and nothing about where they came from: both are facts,
@@ -2850,8 +2873,10 @@ See **Which commands need a capability**.
2850
2873
 
2851
2874
  ### Publishing codes and destinations
2852
2875
 
2853
- Four capabilities accept an object instead of `true` when the provider wants Omni to render real
2854
- choices. `true` remains valid and means "offer the control with nothing published".
2876
+ Four capabilities accept an object when the provider wants Omni to render real choices. For
2877
+ `dispositions` alone, `true` remains valid and means "offer the control with nothing published";
2878
+ the three directory controls -- `coldTransfer`, `warmTransfer`, `conference` -- carry their
2879
+ directory or are refused, since once nothing is typed the directory is the control.
2855
2880
 
2856
2881
  #### `dispositions`
2857
2882
 
@@ -3698,7 +3723,7 @@ choice that is no command at all:
3698
3723
 
3699
3724
  | The lead | The agent's task | The lead's task |
3700
3725
  | --- | --- | --- |
3701
- | `{ type: "lead-assist", action: "take-over" }` | `task-ended` with `{ type: "transferred", destination: leadId }`, straight from `in-progress`: **no `completing` window**, the agent is idle at once | Continues alone, and ends as any call does |
3726
+ | `{ type: "lead-assist", action: "take-over" }` | `task-ended` with `{ type: "transferred", destinationId: leadId }`, straight from `in-progress`: **no `completing` window**, the agent is idle at once | Continues alone, and ends as any call does |
3702
3727
  | `{ type: "lead-assist", action: "leave" }` | Continues; `leadAssist` is cleared | `task-ended` with `{ type: "left" }` -- the call goes on without them |
3703
3728
  | Stays until the customer hangs up | `task-media-ended`, `completing`, its own disposition | The same, independently: **both have the disposal window** |
3704
3729
 
@@ -4134,12 +4159,14 @@ declared:
4134
4159
  | `conference` with `action: "remove"` | The `conference` capability, and somebody else on the call: a remove that would leave the agent alone is `end-call`, and a provider answers it `failed`. |
4135
4160
  | `decline` | The `decline` capability on any channel, **and** Omni provisioning permitting it. One word for refusing an offer, whatever the channel. |
4136
4161
  | `call` | The `preview` phase. A record put in front of an agent is there to be called, so the phase is the gate and there is no capability. It is a dial, with a `dialId` and a `dial-outcome`. |
4137
- | `complete` | `completionMode: "agent-command"`. The `dispositions` capability decides whether a code travels with the command, never whether the command exists — a task Omni cannot complete never ends. |
4162
+ | `complete` | `completionMode: "agent-command"`. The `dispositions` capability decides whether a code travels with the command, never whether the command exists — a task Omni cannot complete never ends. What travels is what the capability published: a code from its list where it has one (`command.complete.disposition.unknown`), a code at all where it requires one (`.disposition.required`), notes as it said (`.notes.required`, `.notes.unexpected`), and neither where the task declares no dispositions (`.disposition.unexpected`). |
4138
4163
  | `connect-back` | The `connectBack` capability **and** the `completing` phase. It exists to reach the party again after the call, so it has no meaning while the call is up. |
4139
4164
  | `transfer` with `action: "warm"` | The `warmTransfer` capability. `action: "cold"` is gated by `coldTransfer`; the two are declared and offered separately. |
4140
4165
  | `transfer` with `action: "complete"` or `"cancel"` | A consultation in progress -- a `consulted` entry on `Task.onCall`. Without one there is nothing to complete or cancel, and a provider that receives either answers `failed`. |
4141
4166
  | `lead-assist` with `action: "request"` or `"cancel"` | The `leadAssist` capability. `cancel` needs a request standing -- `Task.leadAssist` with status `requested`. |
4142
4167
  | `lead-assist` with `action: "take-over"` or `"leave"` | The lead's own task, on a call they joined -- `Task.assisting` present. An agent's task never has it, and a provider that receives either without it answers `failed`. |
4168
+ | `transfer` with a destination, `conference` with `action: "add"` | Its capability, and a `destinationId` the directory offered: the id Omni sends is the id the provider published (`command.destination.unknown`). |
4169
+ | `custom` | A control the task published under `capabilities.custom`, by its `id` (`command.capability.custom`), carrying every field the control's `prompt` asked for (`command.custom.prompt`). |
4143
4170
  | Everything else | Its own named capability. |
4144
4171
 
4145
4172
  **A control on the contact belongs to the handling phases**, `in-progress` and `paused`:
@@ -4150,7 +4177,7 @@ call with nobody on it, a conversation closed -- and a wrap-up that still shows
4150
4177
  for nothing. The capability stays declared, because it is a property of the task and the task is
4151
4178
  still open; the phase says there is nothing to use it on. Omni shows none of these controls outside
4152
4179
  the two phases, and `validateTaskCommand` refuses each of them there (`command.phase.handling`).
4153
- The commands with a phase of their own -- `answer`, `accept`, `decline` and `reject` in `pending`,
4180
+ The commands with a phase of their own -- `answer`, `accept` and `decline` in `pending`,
4154
4181
  `call` in `preview`, `connect-back` in `completing`, `complete` in any -- are not among them.
4155
4182
 
4156
4183
  `validateTaskCommand(command, task)` holds a command to this table at runtime, both ways: the
@@ -4594,7 +4621,18 @@ complete it with a disposition where the agent completes -- and holds every step
4594
4621
  host holds a provider to. Each command is validated against the task as published
4595
4622
  (`drive.command.*`), each answer for its method, a refusal of a control the task offered is a
4596
4623
  violation (`drive.command.failed`), and an event the provider owes and never sends is one too
4597
- (`drive.timeout`, after `driveTimeoutMs`, 5000 by default). With the audio open on a softphone,
4624
+ (`drive.timeout`, after `driveTimeoutMs`, 5000 by default). Around the drive the exercise holds
4625
+ the run to what a host holds a provider to between commands: an offer before the host stated
4626
+ capacity, or beyond the count with nothing dialled on it, is named
4627
+ (`stream.taskOffered.beforeCapacity`, `.overCapacity`); every user the snapshot names is looked up
4628
+ through `describeUsers` and the answer held to the shape, nobody unasked, nobody described as
4629
+ nothing (`describeUsers.user.*`, `describeUsers.unasked`, `connection.describeUsers.empty`); and
4630
+ the second adapter a `rebuild` gives comes up signed in as the same login from the secrets alone
4631
+ before it reads anything (`drive.reload.login`). The result also says which rules the
4632
+ run evaluated, pass or fail, in `rulesEvaluated`: the validators' as each was applied, the stream's
4633
+ as each case was considered, so a test that needs a rule to have run asserts it there rather than
4634
+ inferring it from an empty `violations`, and a rule absent from it was never looked at, which is a
4635
+ gap and not a pass. With the audio open on a softphone,
4598
4636
  the drive mutes it for one second and reports the leg through `recordStep`, begun and then ended,
4599
4637
  expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); and where the provider
4600
4638
  restates the task's record afterwards, the leg is in it or the hole is named
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.66",
3
+ "version": "0.1.67",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",