@xema/omni-protocol 0.1.67 → 0.1.69

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
@@ -26,6 +26,14 @@ export type IsoTimestamp = string;
26
26
  export type UserId = string;
27
27
  /** A non-empty, opaque task identifier unique within one provider. Scope it with `taskKey()`. */
28
28
  export type TaskId = string;
29
+ /**
30
+ * One life of a task on the wire. A platform retires a task id minutes after closing it and a
31
+ * requeue takes seconds, so the same `TaskId` comes back for another customer while a late dial
32
+ * outcome or a late handling report for the first is still in flight. The allocation id is minted
33
+ * once per offer and never reused for the life of the login, and every event, command and report
34
+ * that names a task names its allocation too, so nothing lands on the next life of the id.
35
+ */
36
+ export type AllocationId = string;
29
37
  /**
30
38
  * The host's identity for one dial, minted before the command leaves and unique across the
31
39
  * installation. A dial is the one command whose outcome arrives later and apart, so the provider
@@ -770,6 +778,7 @@ export interface TaskAssisting {
770
778
  export interface TaskMonitoring {
771
779
  memberId: UserId;
772
780
  taskId: TaskId;
781
+ allocationId: AllocationId;
773
782
  mode: MonitorMode;
774
783
  since: IsoTimestamp;
775
784
  }
@@ -826,6 +835,8 @@ export type CapabilitySource = "queue" | "ungoverned" | "undetermined";
826
835
  export declare const CAPABILITY_SOURCES: readonly ["queue", "ungoverned", "undetermined"];
827
836
  export type Task<C extends Channel = Channel> = {
828
837
  id: TaskId;
838
+ /** This life of the task: minted once per offer, never reused for the life of the login. See `AllocationId`. */
839
+ allocationId: AllocationId;
829
840
  title: string;
830
841
  channel: C;
831
842
  /** The provider's own name for a category of work. Finer-grained than a channel. */
@@ -1035,6 +1046,7 @@ export interface CustomTaskCommand {
1035
1046
  export type TaskCommand<C extends Channel = Channel> = (C extends "voice" ? VoiceTaskCommand : C extends "chat" ? ChatTaskCommand : EmailTaskCommand) | CustomTaskCommand;
1036
1047
  export interface TaskCommandRequest<C extends Channel = Channel> {
1037
1048
  taskId: TaskId;
1049
+ allocationId: AllocationId;
1038
1050
  command: TaskCommand<C>;
1039
1051
  }
1040
1052
  /**
@@ -1182,6 +1194,7 @@ export interface LeadRequest {
1182
1194
  id: string;
1183
1195
  memberId: UserId;
1184
1196
  taskId: TaskId;
1197
+ allocationId: AllocationId;
1185
1198
  note?: string;
1186
1199
  since: IsoTimestamp;
1187
1200
  }
@@ -1280,6 +1293,7 @@ export interface VoiceMediaSession {
1280
1293
  }
1281
1294
  export interface OpenMediaRequest {
1282
1295
  taskId: TaskId;
1296
+ allocationId: AllocationId;
1283
1297
  /** The agent's microphone as Omni captured it, `HostReport.audio.input.localAudio`; absent while that input is `unavailable`. */
1284
1298
  localAudio?: MediaStream;
1285
1299
  }
@@ -1339,6 +1353,7 @@ export type SetPreferenceRequest = {
1339
1353
  */
1340
1354
  export type HandlingReport = {
1341
1355
  taskId: TaskId;
1356
+ allocationId: AllocationId;
1342
1357
  at: IsoTimestamp;
1343
1358
  } & (
1344
1359
  /** A muted leg says whose the silence was: the host's own Mute, or the station's slider or system. */
@@ -1427,12 +1442,15 @@ export type ProviderEvent<C extends Channel = Channel> = {
1427
1442
  } | {
1428
1443
  type: "task-media-started";
1429
1444
  taskId: TaskId;
1445
+ allocationId: AllocationId;
1430
1446
  } | {
1431
1447
  type: "task-media-ended";
1432
1448
  taskId: TaskId;
1449
+ allocationId: AllocationId;
1433
1450
  } | {
1434
1451
  type: "task-ended";
1435
1452
  taskId: TaskId;
1453
+ allocationId: AllocationId;
1436
1454
  outcome: TaskOutcome;
1437
1455
  }
1438
1456
  /**
@@ -1447,6 +1465,7 @@ export type ProviderEvent<C extends Channel = Channel> = {
1447
1465
  dialId: DialId;
1448
1466
  outcome: DialOutcome;
1449
1467
  taskId?: TaskId;
1468
+ allocationId?: AllocationId;
1450
1469
  destinationId?: string;
1451
1470
  reason?: string;
1452
1471
  } | {
@@ -1463,6 +1482,7 @@ export type ProviderEvent<C extends Channel = Channel> = {
1463
1482
  expected: string;
1464
1483
  observed: string;
1465
1484
  taskId?: TaskId;
1485
+ allocationId?: AllocationId;
1466
1486
  } | {
1467
1487
  type: "team-updated";
1468
1488
  team: TeamRoster;
@@ -1487,10 +1507,24 @@ export interface ProviderEventEnvelope<C extends Channel = Channel> {
1487
1507
  }
1488
1508
  export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.task-not-found", "omni.destination-not-permitted", "omni.phone-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed"];
1489
1509
  export type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
1510
+ /**
1511
+ * What the host refused, told to the adapter that published it: a snapshot the host would not
1512
+ * replace its state with, or an event it dropped, and every rule it broke. A refusal the provider
1513
+ * never hears of is a desk frozen at its last good state for a shift with nothing wrong on the
1514
+ * provider's side; this is how both sides see it.
1515
+ */
1516
+ export interface Refusal {
1517
+ artefact: "snapshot" | "event";
1518
+ /** The envelope refused, where the artefact is an event. */
1519
+ envelopeId?: string;
1520
+ violations: ProtocolViolation[];
1521
+ }
1490
1522
  export interface Connection<C extends Channel = Channel> {
1491
1523
  snapshot(): Snapshot<C> | Promise<Snapshot<C>>;
1492
1524
  /** Delivery order must match the order the provider observes changes. Never replay. */
1493
1525
  subscribe(listener: (envelope: ProviderEventEnvelope<C>) => void): Unsubscribe;
1526
+ /** Told what the host refused, snapshot or event, with every rule broken. Always; a refusal is visible on both sides. */
1527
+ refused(report: Refusal): void;
1494
1528
  /** Nothing may be allocated until a capacity is stated, so every connection receives it. */
1495
1529
  setCapacity(capacity: AgentCapacity): Promise<CapacityResult>;
1496
1530
  execute(request: TaskCommandRequest<C>): Promise<TaskCommandResult>;
@@ -1603,6 +1637,8 @@ export interface BrowserSessionKeyInput {
1603
1637
  /** `Manifest.id`, never `displayName`: only the id is unique across an installation and stable. */
1604
1638
  providerId: string;
1605
1639
  taskId: TaskId;
1640
+ /** The task's allocation: a task-scoped session lives one life of the task, never the next customer's under a reused id. */
1641
+ allocationId: AllocationId;
1606
1642
  /** `Task.taskType`. */
1607
1643
  taskType: string;
1608
1644
  browser: TaskBrowser;
package/dist/index.js CHANGED
@@ -196,7 +196,7 @@ export function sameCapabilities(a, b) {
196
196
  * cannot collide with a provider called `a` and a tab called `b`.
197
197
  */
198
198
  export function browserSessionKey(input) {
199
- const { providerId, taskId, taskType, browser } = input;
199
+ const { providerId, allocationId, taskType, browser } = input;
200
200
  if (browser.sharedSession !== true)
201
201
  return undefined;
202
202
  // `encodeURIComponent` leaves `.` untouched, and `.` is the separator: a raw join would let
@@ -204,7 +204,7 @@ export function browserSessionKey(input) {
204
204
  const part = (value) => encodeURIComponent(value).replaceAll(".", "%2E");
205
205
  switch (browser.isolationScheme) {
206
206
  case BROWSER_ISOLATION_SCHEMES.PROVIDER_NAME__TASK_ID__TAB_NAME:
207
- return `${part(providerId)}.${part(taskId)}.${part(browser.name)}`;
207
+ return `${part(providerId)}.${part(allocationId)}.${part(browser.name)}`;
208
208
  case BROWSER_ISOLATION_SCHEMES.TAB_NAME:
209
209
  return part(browser.name);
210
210
  case BROWSER_ISOLATION_SCHEMES.PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME:
package/dist/testing.d.ts CHANGED
@@ -135,10 +135,18 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
135
135
  export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
136
136
  export declare class TaskStream {
137
137
  private readonly tasks;
138
+ private readonly allocations;
139
+ private readonly endedAllocations;
138
140
  private readonly dials;
139
141
  /** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
140
142
  dialled(dialId: string): void;
141
143
  private noteDials;
144
+ /**
145
+ * Whether an event about a task names the life that is open under its id. One naming a life that
146
+ * has ended is the late event the allocation exists to catch; one naming a life nobody has seen is
147
+ * a different fault. Either way it is not applied to the open task.
148
+ */
149
+ private namesTheOpenLife;
142
150
  /** How many tasks the stream currently holds open. */
143
151
  openCount(): number;
144
152
  /** 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. */
package/dist/testing.js CHANGED
@@ -292,6 +292,19 @@ export async function exerciseAdapter(adapter, context, options = {}) {
292
292
  const connected = { ...context, host, store: watched.store };
293
293
  connection = await adapter.connect(connected);
294
294
  const live = connection;
295
+ // A refusal is visible on both sides: what the host would not take, the adapter is told, with the rules.
296
+ requireMethod(live, "refused", "every connection is told what the host refused");
297
+ const tellRefused = (report) => {
298
+ if (typeof live.refused !== "function")
299
+ return;
300
+ ruleEvaluated("connection.refused.rejected");
301
+ try {
302
+ live.refused(report);
303
+ }
304
+ catch (error) {
305
+ violations.push({ rule: "connection.refused.rejected", path: "connection.refused", message: `told of a refusal, the adapter threw: ${String(error)}` });
306
+ }
307
+ };
295
308
  // Dial is declared by presence: the capability object carries a destination policy rather
296
309
  // than an `enabled` flag, so its presence is the declaration.
297
310
  if (adapter.manifest.idleCapabilities?.dial !== undefined)
@@ -305,14 +318,23 @@ export async function exerciseAdapter(adapter, context, options = {}) {
305
318
  requireMethod(live, "recordStep", "the login is on a softphone, whose microphone the host mutes");
306
319
  const eventPayloads = new Map();
307
320
  let capacityStated;
321
+ const reloading = { now: false };
308
322
  // The drive waits on events: each waiter is offered every envelope as it lands.
309
323
  const waiters = new Set();
310
324
  unsubscribe = connection.subscribe(envelope => {
311
325
  observeEvent(envelope, seen);
312
- violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader()));
326
+ const broken = validateEventEnvelope(envelope, adapter.manifest, "event", reader());
327
+ violations.push(...broken);
328
+ if (broken.length > 0)
329
+ tellRefused({ artefact: "event", envelopeId: typeof envelope?.id === "string" ? envelope.id : undefined, violations: broken });
313
330
  // A diagnostic is informational to a host and a failure to a conformance run: the platform
314
331
  // under test broke a rule the adapter relies on, and a green result must not paper over it.
315
- if (isRecord(envelope?.event) && envelope.event.type === "diagnostic") {
332
+ // A reload in life has no first connection: the page is gone. The drive keeps its first
333
+ // connection up while the second reads, so a platform that pushes the open task to the new
334
+ // client pushes it to the old one too, and an adapter that calls a re-offer of answered work a
335
+ // defect is right to. That diagnostic is the drive's own artefact, and is not counted while the
336
+ // second adapter is up; every other moment it is.
337
+ if (isRecord(envelope?.event) && envelope.event.type === "diagnostic" && !reloading.now) {
316
338
  violations.push({ rule: "diagnostic.raised", path: "event.diagnostic",
317
339
  message: `the provider reported a diagnostic: expected ${String(envelope.event.expected)}; observed ${String(envelope.event.observed)}` });
318
340
  }
@@ -355,7 +377,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
355
377
  });
356
378
  const snapshot = await connection.snapshot();
357
379
  observeSnapshot(snapshot, seen);
358
- violations.push(...validateSnapshot(snapshot, adapter.manifest, "snapshot", reader()));
380
+ {
381
+ const broken = validateSnapshot(snapshot, adapter.manifest, "snapshot", reader());
382
+ violations.push(...broken);
383
+ if (broken.length > 0)
384
+ tellRefused({ artefact: "snapshot", violations: broken });
385
+ }
359
386
  violations.push(...undeterminedTasks(Array.isArray(snapshot?.tasks) ? snapshot.tasks : [], "snapshot.tasks"));
360
387
  stream.seed(snapshot);
361
388
  breaks.seed(snapshot);
@@ -422,7 +449,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
422
449
  violations.push(...await driveOneCall({
423
450
  connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
424
451
  timeoutMs: options.driveTimeoutMs ?? 5000,
425
- context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held,
452
+ context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, reloading,
426
453
  }));
427
454
  }
428
455
  }
@@ -817,6 +844,11 @@ const REACHABLE_PHASES = {
817
844
  };
818
845
  export class TaskStream {
819
846
  tasks = new Map();
847
+ // Every allocation this login has seen, and the ones whose task has ended: an offer never reuses
848
+ // one, an event about a task names the life that is open, and a late event for a life that ended
849
+ // is recognised as that rather than landing on the next customer under the same id.
850
+ allocations = new Set();
851
+ endedAllocations = new Set();
820
852
  // Every dial the stream can place an outcome against: one the host said it placed, or one a
821
853
  // task carried on `onCall` or in its record -- which is how a dial made before a transfer is known
822
854
  // to whoever holds the task now. `answered` or `ended` once its outcome arrived, since it comes once.
@@ -838,6 +870,23 @@ export class TaskStream {
838
870
  this.dialled(entry.dialId);
839
871
  }
840
872
  }
873
+ /**
874
+ * Whether an event about a task names the life that is open under its id. One naming a life that
875
+ * has ended is the late event the allocation exists to catch; one naming a life nobody has seen is
876
+ * a different fault. Either way it is not applied to the open task.
877
+ */
878
+ namesTheOpenLife(event, known, at, refuse) {
879
+ const named = event.allocationId;
880
+ if (typeof named !== "string" || named === known.allocation)
881
+ return true;
882
+ if (this.endedAllocations.has(named)) {
883
+ 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`);
884
+ }
885
+ else {
886
+ refuse("stream.allocation.unknown", `${at}.allocationId`, `${named} is not an allocation this login has seen`);
887
+ }
888
+ return false;
889
+ }
841
890
  /** How many tasks the stream currently holds open. */
842
891
  openCount() { return this.tasks.size; }
843
892
  /** 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. */
@@ -876,7 +925,7 @@ export class TaskStream {
876
925
  stages.set(entry.dialId, entry.stage);
877
926
  }
878
927
  }
879
- return { phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task) };
928
+ 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) };
880
929
  }
881
930
  /** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
882
931
  seed(snapshot) {
@@ -884,8 +933,11 @@ export class TaskStream {
884
933
  if (!isRecord(snapshot) || !Array.isArray(snapshot.tasks))
885
934
  return;
886
935
  for (const task of snapshot.tasks) {
887
- if (isRecord(task) && typeof task.id === "string")
936
+ if (isRecord(task) && typeof task.id === "string") {
888
937
  this.tasks.set(task.id, TaskStream.stated(task));
938
+ if (typeof task.allocationId === "string")
939
+ this.allocations.add(task.allocationId);
940
+ }
889
941
  this.noteDials(task);
890
942
  }
891
943
  }
@@ -920,19 +972,27 @@ export class TaskStream {
920
972
  }
921
973
  this.seed(event.snapshot);
922
974
  break;
923
- case "task-offered":
924
- ruleEvaluated("stream.taskOffered.duplicate");
975
+ case "task-offered": {
976
+ ruleEvaluated("stream.taskOffered.duplicate", "stream.taskOffered.allocation");
925
977
  if (id === undefined)
926
978
  break;
927
979
  if (known !== undefined)
928
980
  refuse("stream.taskOffered.duplicate", `${at}.task.id`, `${id} is already on the stream; an offer introduces a task once`);
981
+ const allocation = isRecord(event.task) ? event.task.allocationId : undefined;
982
+ if (typeof allocation === "string") {
983
+ if (this.allocations.has(allocation)) {
984
+ 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`);
985
+ }
986
+ this.allocations.add(allocation);
987
+ }
929
988
  this.tasks.set(id, TaskStream.stated(event.task));
930
989
  this.noteDials(event.task);
931
990
  break;
991
+ }
932
992
  case "task-updated":
933
993
  if (id === undefined)
934
994
  break;
935
- ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
995
+ 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");
936
996
  if (known === undefined) {
937
997
  refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
938
998
  break;
@@ -943,6 +1003,11 @@ export class TaskStream {
943
1003
  if ((known.source === "queue" || known.source === "ungoverned") && isRecord(event.task) && event.task.capabilitySource === "undetermined") {
944
1004
  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`);
945
1005
  }
1006
+ // An update is of the life that is open. One carrying another allocation is a copy of a
1007
+ // different life of this id -- a stale republish of the last customer's call, or the next one's.
1008
+ if (isRecord(event.task) && String(event.task.allocationId) !== known.allocation) {
1009
+ 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`);
1010
+ }
946
1011
  // A task does not go backwards. The stream sees publications, not transitions, and a task may
947
1012
  // pass through a phase between two, so what is refused is a phase unreachable from the last
948
1013
  // one read by the guide's table: back to pending, back to confirmed once work began, out of
@@ -993,13 +1058,15 @@ export class TaskStream {
993
1058
  this.noteDials(event.task);
994
1059
  break;
995
1060
  case "task-media-started":
996
- ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate");
1061
+ ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate", "stream.allocation.ended", "stream.allocation.unknown");
997
1062
  if (id === undefined)
998
1063
  break;
999
1064
  if (known === undefined) {
1000
1065
  refuse("stream.taskMediaStarted.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
1001
1066
  break;
1002
1067
  }
1068
+ if (!this.namesTheOpenLife(event, known, at, refuse))
1069
+ break;
1003
1070
  if (!AT_WORK.has(known.phase)) {
1004
1071
  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`);
1005
1072
  }
@@ -1009,13 +1076,15 @@ export class TaskStream {
1009
1076
  known.media = "started";
1010
1077
  break;
1011
1078
  case "task-media-ended":
1012
- ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent");
1079
+ ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent", "stream.allocation.ended", "stream.allocation.unknown");
1013
1080
  if (id === undefined)
1014
1081
  break;
1015
1082
  if (known === undefined) {
1016
1083
  refuse("stream.taskMediaEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
1017
1084
  break;
1018
1085
  }
1086
+ if (!this.namesTheOpenLife(event, known, at, refuse))
1087
+ break;
1019
1088
  if (!WORK_BEGUN.has(known.phase)) {
1020
1089
  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`);
1021
1090
  }
@@ -1025,15 +1094,27 @@ export class TaskStream {
1025
1094
  known.media = "ended";
1026
1095
  break;
1027
1096
  case "task-ended":
1097
+ ruleEvaluated("stream.taskEnded.unknown", "stream.allocation.ended", "stream.allocation.unknown");
1028
1098
  if (id === undefined)
1029
1099
  break;
1030
- if (known === undefined)
1100
+ if (known === undefined) {
1031
1101
  refuse("stream.taskEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
1102
+ break;
1103
+ }
1104
+ // A late ending for a life that is over must not end the life that is open under the same id.
1105
+ if (!this.namesTheOpenLife(event, known, at, refuse))
1106
+ break;
1107
+ this.endedAllocations.add(known.allocation);
1032
1108
  this.tasks.delete(id);
1033
1109
  break;
1034
1110
  case "dial-outcome": {
1035
1111
  // An outcome ends a dial somebody placed, once. The task it names may already have ended;
1036
- // a dial placed late routinely outlives its call, which is why the dial has its own identity.
1112
+ // a dial placed late routinely outlives its call, which is why the dial has its own identity,
1113
+ // and why the outcome names the allocation: the host routes it to that life, ended or not.
1114
+ ruleEvaluated("stream.allocation.unknown");
1115
+ if (typeof event.allocationId === "string" && !this.allocations.has(event.allocationId)) {
1116
+ refuse("stream.allocation.unknown", `${at}.allocationId`, `${event.allocationId} is not an allocation this login has seen`);
1117
+ }
1037
1118
  if (typeof event.dialId !== "string")
1038
1119
  break;
1039
1120
  const dial = this.dials.get(event.dialId);
@@ -1088,6 +1169,7 @@ async function driveOneCall(drive) {
1088
1169
  drive.waiters.add(waiter);
1089
1170
  });
1090
1171
  const recordSurvivesReload = async (at) => {
1172
+ ruleEvaluated("drive.reload.rejected", "drive.reload.manifest", "drive.reload.login", "drive.reload.snapshot", "drive.reload.allocation", "drive.reload.history");
1091
1173
  let again;
1092
1174
  try {
1093
1175
  again = drive.rebuild();
@@ -1104,6 +1186,7 @@ async function driveOneCall(drive) {
1104
1186
  refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
1105
1187
  return;
1106
1188
  }
1189
+ drive.reloading.now = true;
1107
1190
  session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
1108
1191
  // The reload is a restore before it is anything else: the second session stands authenticated
1109
1192
  // as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
@@ -1124,6 +1207,10 @@ async function driveOneCall(drive) {
1124
1207
  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`);
1125
1208
  return;
1126
1209
  }
1210
+ // The allocation is part of the task, not memory beside it: a reload brings the same life back.
1211
+ if (carried.allocationId !== allocationOf()) {
1212
+ 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`);
1213
+ }
1127
1214
  const history = carried.handlingHistory;
1128
1215
  const steps = isRecord(history) && Array.isArray(history.steps) ? history.steps : [];
1129
1216
  // A record once read is not unread across a reload either: every entry the first adapter published is here.
@@ -1158,6 +1245,7 @@ async function driveOneCall(drive) {
1158
1245
  catch (error) {
1159
1246
  refuse("drive.reload.rejected", "drive.reload", `the second adapter's session threw on close: ${String(error)}`);
1160
1247
  }
1248
+ drive.reloading.now = false;
1161
1249
  }
1162
1250
  };
1163
1251
  // A task's keys are gone with the task. A task-scoped key carries the task id, so the watched
@@ -1165,6 +1253,7 @@ async function driveOneCall(drive) {
1165
1253
  // inherited by the next offer of the same id, since a platform retires an id minutes after closing it.
1166
1254
  let retainedAtEnd;
1167
1255
  const storeReleased = async () => {
1256
+ ruleEvaluated("drive.store.retained");
1168
1257
  await Promise.resolve();
1169
1258
  await Promise.resolve();
1170
1259
  const retained = [...drive.held].filter(key => key.includes(taskId));
@@ -1178,6 +1267,7 @@ async function driveOneCall(drive) {
1178
1267
  const nothingLate = () => {
1179
1268
  if (retainedAtEnd === undefined)
1180
1269
  return;
1270
+ ruleEvaluated("drive.store.late");
1181
1271
  const late = [...drive.held].filter(key => key.includes(taskId) && !retainedAtEnd.has(key));
1182
1272
  if (late.length > 0) {
1183
1273
  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`);
@@ -1197,6 +1287,7 @@ async function driveOneCall(drive) {
1197
1287
  cursor = offered.at;
1198
1288
  }
1199
1289
  const taskId = task.id;
1290
+ const allocationOf = () => String(latestTask().allocationId);
1200
1291
  const latestTask = () => task;
1201
1292
  const updated = (until, what) => waitFor(what, envelope => {
1202
1293
  const event = envelope.event;
@@ -1212,14 +1303,16 @@ async function driveOneCall(drive) {
1212
1303
  return event.type === "task-ended" && event.taskId === taskId ? event : undefined;
1213
1304
  }, cursor);
1214
1305
  // Every command the drive sends is validated against the task as published, and its answer for its method.
1306
+ ruleEvaluated("drive.timeout");
1215
1307
  const send = async (command, dialId) => {
1308
+ ruleEvaluated("drive.command.rejected", "drive.command.failed");
1216
1309
  const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`);
1217
1310
  found.push(...own);
1218
1311
  if (own.length > 0)
1219
1312
  return undefined;
1220
1313
  let result;
1221
1314
  try {
1222
- result = await drive.connection.execute({ taskId, command });
1315
+ result = await drive.connection.execute({ taskId, allocationId: allocationOf(), command });
1223
1316
  }
1224
1317
  catch (error) {
1225
1318
  refuse("drive.command.rejected", `drive.command.${String(command.type)}`, `execute rejected rather than answered: ${String(error)}`);
@@ -1261,10 +1354,11 @@ async function driveOneCall(drive) {
1261
1354
  // control still declared: a host holds it back (command.phase.handling), and an adapter that
1262
1355
  // receives it anyway must refuse it -- so the drive sends it past the validator and expects failed.
1263
1356
  const holdRefusedOutsideHandling = async () => {
1357
+ ruleEvaluated("drive.command.rejected", "drive.command.handling");
1264
1358
  const phase = String(latestTask().phase);
1265
1359
  let answer;
1266
1360
  try {
1267
- answer = await drive.connection.execute({ taskId, command: { type: "hold" } });
1361
+ answer = await drive.connection.execute({ taskId, allocationId: allocationOf(), command: { type: "hold" } });
1268
1362
  }
1269
1363
  catch (error) {
1270
1364
  refuse("drive.command.rejected", "drive.command.hold", `execute rejected rather than answered: ${String(error)}`);
@@ -1291,7 +1385,7 @@ async function driveOneCall(drive) {
1291
1385
  return found;
1292
1386
  cursor = Math.max(cursor, started.at);
1293
1387
  }
1294
- const opened = await drive.connection.openMedia?.({ taskId, localAudio: drive.localAudio });
1388
+ const opened = await drive.connection.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
1295
1389
  found.push(...validateResult(opened, "openMedia", "drive.openMedia"));
1296
1390
  if (isRecord(opened) && opened.status === "opened")
1297
1391
  session = opened.session;
@@ -1305,8 +1399,9 @@ async function driveOneCall(drive) {
1305
1399
  if (session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function") {
1306
1400
  const at = new Date().toISOString();
1307
1401
  const report = async (body) => {
1402
+ ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
1308
1403
  // The drive holds its own report to the contract before it crosses, as a host must.
1309
- const leg = { taskId, step: "muted", at, mutedBy: "host", ...body };
1404
+ const leg = { taskId, allocationId: allocationOf(), step: "muted", at, mutedBy: "host", ...body };
1310
1405
  const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
1311
1406
  found.push(...own);
1312
1407
  if (own.length > 0)
@@ -131,6 +131,7 @@ 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 isAllocationId = isFilled;
134
135
  const ruleListeners = new Set();
135
136
  /**
136
137
  * Hears every rule a validator evaluates, pass or fail, until the returned function is called. A
@@ -941,6 +942,7 @@ function validateMonitoring(value, channel, path, into) {
941
942
  }
942
943
  into.require(isUserId(value.memberId), "task.monitoring.memberId", `${path}.memberId`, "a monitored call names the member on it");
943
944
  into.require(isTaskId(value.taskId), "task.monitoring.taskId", `${path}.taskId`, "a monitored call names the member's task");
945
+ into.require(isAllocationId(value.allocationId), "task.monitoring.allocationId", `${path}.allocationId`, "a monitored call names the member's task by its allocation too");
944
946
  into.oneOf(value.mode, MONITOR_MODES, "task.monitoring.mode", `${path}.mode`);
945
947
  into.timestamp(value.since, "task.monitoring.since", `${path}.since`);
946
948
  }
@@ -955,6 +957,7 @@ function validateTaskInto(task, context, path, into) {
955
957
  return;
956
958
  }
957
959
  into.require(isTaskId(task.id), "task.id", `${path}.id`, "a task needs a non-empty id");
960
+ into.require(isAllocationId(task.allocationId), "task.allocationId", `${path}.allocationId`, "a task names this life of itself: an allocation id minted once per offer, never reused for the life of the login");
958
961
  into.filled(task.title, "task.title", `${path}.title`, "a task needs a title");
959
962
  into.filled(task.taskType, "task.taskType", `${path}.taskType`, "a task needs a task type");
960
963
  if (into.oneOf(task.phase, TASK_PHASES, "task.phase", `${path}.phase`) && task.phase === "preview") {
@@ -1234,6 +1237,7 @@ function validateTeamRosterInto(roster, path, context, into) {
1234
1237
  into.require(request.memberId !== context.self, "team.request.self", `${at}.memberId`, "the roster carries the reader's own ask: an agent's request for a lead goes to whoever leads them");
1235
1238
  }
1236
1239
  into.require(isTaskId(request.taskId), "team.request.taskId", `${at}.taskId`, "a request names the task the lead would join");
1240
+ into.require(isAllocationId(request.allocationId), "team.request.allocationId", `${at}.allocationId`, "a request names the task by its allocation too");
1237
1241
  if (request.note !== undefined)
1238
1242
  into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
1239
1243
  into.timestamp(request.since, "team.request.since", `${at}.since`);
@@ -1522,12 +1526,15 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1522
1526
  break;
1523
1527
  case "task-media-started":
1524
1528
  into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
1529
+ into.require(isAllocationId(event.allocationId), "event.taskMediaStarted.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
1525
1530
  break;
1526
1531
  case "task-media-ended":
1527
1532
  into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
1533
+ into.require(isAllocationId(event.allocationId), "event.taskMediaEnded.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
1528
1534
  break;
1529
1535
  case "task-ended":
1530
1536
  into.require(isTaskId(event.taskId), "event.taskEnded.taskId", `${at}.taskId`, "a task id is required");
1537
+ into.require(isAllocationId(event.allocationId), "event.taskEnded.allocationId", `${at}.allocationId`, "an event about a task names its allocation");
1531
1538
  validateTaskOutcome(event.outcome, `${at}.outcome`, into);
1532
1539
  break;
1533
1540
  case "dial-outcome": {
@@ -1539,6 +1546,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1539
1546
  }
1540
1547
  if (event.taskId !== undefined)
1541
1548
  into.require(isTaskId(event.taskId), "event.dialOutcome.taskId", `${at}.taskId`, "taskId must be a task id when present");
1549
+ allocationWithTask(event, "event.dialOutcome", at, into);
1542
1550
  if (event.destinationId !== undefined)
1543
1551
  into.filled(event.destinationId, "event.dialOutcome.destinationId", `${at}.destinationId`, "a destinationId must not be empty when present");
1544
1552
  if (event.reason !== undefined)
@@ -1559,6 +1567,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1559
1567
  into.filled(event.observed, "event.diagnostic.observed", `${at}.observed`, "a diagnostic states what was observed instead");
1560
1568
  if (event.taskId !== undefined)
1561
1569
  into.require(isTaskId(event.taskId), "event.diagnostic.taskId", `${at}.taskId`, "taskId must be a task id when present");
1570
+ allocationWithTask(event, "event.diagnostic", at, into);
1562
1571
  break;
1563
1572
  case "queue-summary":
1564
1573
  validateQueueSummary(event.summary, `${at}.summary`, into);
@@ -1744,6 +1753,7 @@ export function validateHandlingReport(report, path = "handlingReport", manifest
1744
1753
  return into.violations;
1745
1754
  }
1746
1755
  into.require(isTaskId(report.taskId), "handlingReport.taskId", `${path}.taskId`, "a report names the task");
1756
+ into.require(isAllocationId(report.allocationId), "handlingReport.allocationId", `${path}.allocationId`, "a report names the task's allocation, so a late one never lands on the next life of the id");
1747
1757
  into.oneOf(report.step, HANDLING_STEPS, "handlingReport.step", `${path}.step`);
1748
1758
  into.timestamp(report.at, "handlingReport.at", `${path}.at`);
1749
1759
  // A muted leg says whose the silence was; no other leg has anyone to name for it.
@@ -1776,6 +1786,15 @@ function validateMutedBy(device, rule, path, into) {
1776
1786
  into.require(device.mutedBy === undefined, `${rule}.mutedBy.unexpected`, `${path}.mutedBy`, "mutedBy says who silenced a device that is not flowing; a flowing one names nobody");
1777
1787
  }
1778
1788
  }
1789
+ /** An event that may name a task names its allocation with it, and never an allocation alone. */
1790
+ function allocationWithTask(event, rule, at, into) {
1791
+ if (event.taskId !== undefined) {
1792
+ into.require(isAllocationId(event.allocationId), `${rule}.allocationId`, `${at}.allocationId`, "an event that names a task names its allocation too");
1793
+ }
1794
+ else {
1795
+ into.require(event.allocationId === undefined, `${rule}.allocationId.unexpected`, `${at}.allocationId`, "an allocation is named with its task, never alone");
1796
+ }
1797
+ }
1779
1798
  export function validateHostReport(report, path = "host") {
1780
1799
  const into = new Collector();
1781
1800
  if (!isPlainObject(report)) {
package/guide.md CHANGED
@@ -97,6 +97,7 @@ the semantic name in every contract field rather than repeating the primitive ty
97
97
  type IsoTimestamp = string;
98
98
  type UserId = string;
99
99
  type TaskId = string;
100
+ type AllocationId = string;
100
101
  type DialId = string;
101
102
  type DurationSeconds = number;
102
103
  ```
@@ -396,7 +397,7 @@ type SetPreferenceRequest =
396
397
  | { id: PreferenceId; enabled: boolean }
397
398
  | { id: PreferenceId; inherit: true };
398
399
 
399
- type HandlingReport = { taskId: TaskId; at: IsoTimestamp } & (
400
+ type HandlingReport = { taskId: TaskId; allocationId: AllocationId; at: IsoTimestamp } & (
400
401
  | { step: "muted"; mutedBy: MutedBy }
401
402
  | { step: Exclude<HandlingStep, "muted">; mutedBy?: never }
402
403
  ) & (
@@ -552,6 +553,7 @@ type PersonalBrowser = Browser;
552
553
  type BrowserSessionKeyInput = {
553
554
  providerId: string;
554
555
  taskId: TaskId;
556
+ allocationId: AllocationId;
555
557
  taskType: string;
556
558
  browser: TaskBrowser;
557
559
  };
@@ -643,6 +645,7 @@ type TaskAssisting = {
643
645
  type TaskMonitoring = {
644
646
  memberId: UserId;
645
647
  taskId: TaskId;
648
+ allocationId: AllocationId;
646
649
  mode: MonitorMode;
647
650
  since: IsoTimestamp;
648
651
  };
@@ -667,6 +670,7 @@ type CapabilitySource = "queue" | "ungoverned" | "undetermined";
667
670
 
668
671
  type Task<C extends Channel = Channel> = {
669
672
  id: TaskId;
673
+ allocationId: AllocationId;
670
674
  title: string;
671
675
  channel: C;
672
676
  taskType: string;
@@ -772,6 +776,7 @@ type TaskCommand<C extends Channel = Channel> =
772
776
 
773
777
  type TaskCommandRequest<C extends Channel = Channel> = {
774
778
  taskId: TaskId;
779
+ allocationId: AllocationId;
775
780
  command: TaskCommand<C>;
776
781
  };
777
782
 
@@ -924,6 +929,7 @@ type LeadRequest = {
924
929
  id: string;
925
930
  memberId: UserId;
926
931
  taskId: TaskId;
932
+ allocationId: AllocationId;
927
933
  note?: string;
928
934
  since: IsoTimestamp;
929
935
  };
@@ -1003,6 +1009,7 @@ type OpenMediaResult =
1003
1009
 
1004
1010
  type OpenMediaRequest = {
1005
1011
  taskId: TaskId;
1012
+ allocationId: AllocationId;
1006
1013
  localAudio?: MediaStream;
1007
1014
  };
1008
1015
  ```
@@ -1033,13 +1040,13 @@ type ProviderEvent =
1033
1040
  allocationExpiresAt?: IsoTimestamp;
1034
1041
  }
1035
1042
  | { type: "task-updated"; task: Task }
1036
- | { type: "task-media-started"; taskId: TaskId }
1037
- | { type: "task-media-ended"; taskId: TaskId }
1038
- | { type: "task-ended"; taskId: TaskId; outcome: TaskOutcome }
1039
- | { type: "dial-outcome"; dialId: DialId; outcome: DialOutcome; taskId?: TaskId; destinationId?: string; reason?: string }
1043
+ | { type: "task-media-started"; taskId: TaskId; allocationId: AllocationId }
1044
+ | { type: "task-media-ended"; taskId: TaskId; allocationId: AllocationId }
1045
+ | { type: "task-ended"; taskId: TaskId; allocationId: AllocationId; outcome: TaskOutcome }
1046
+ | { type: "dial-outcome"; dialId: DialId; outcome: DialOutcome; taskId?: TaskId; allocationId?: AllocationId; destinationId?: string; reason?: string }
1040
1047
  | { type: "announcement"; text: string; html?: string; announcedAt: IsoTimestamp; expiresAt?: IsoTimestamp }
1041
1048
  | { type: "queue-summary"; summary: QueueSummary }
1042
- | { type: "diagnostic"; expected: string; observed: string; taskId?: TaskId }
1049
+ | { type: "diagnostic"; expected: string; observed: string; taskId?: TaskId; allocationId?: AllocationId }
1043
1050
  | { type: "team-updated"; team: TeamRoster }
1044
1051
  | { type: "contacts-updated"; contacts: Contact[] }
1045
1052
  | { type: "calendar-updated"; scheduledActivities: ScheduledActivity[] };
@@ -1059,9 +1066,16 @@ adapter compiled against the browser lib.
1059
1066
  ### Adapter and connection
1060
1067
 
1061
1068
  ```ts
1069
+ type Refusal = {
1070
+ artefact: "snapshot" | "event";
1071
+ envelopeId?: string;
1072
+ violations: ProtocolViolation[];
1073
+ };
1074
+
1062
1075
  type Connection<C extends Channel = Channel> = {
1063
1076
  snapshot(): Snapshot<C> | Promise<Snapshot<C>>;
1064
1077
  subscribe(listener: (envelope: ProviderEventEnvelope<C>) => void): Unsubscribe;
1078
+ refused(report: Refusal): void;
1065
1079
  setCapacity(capacity: AgentCapacity): Promise<CapacityResult>;
1066
1080
  execute(request: TaskCommandRequest<C>): Promise<TaskCommandResult>;
1067
1081
  disconnect(): Promise<void>;
@@ -2021,6 +2035,7 @@ surface in one place, and what obliges an adapter to implement each one.
2021
2035
  | `snapshot()` | Always. |
2022
2036
  | `subscribe(listener)` | Always. |
2023
2037
  | `disconnect()` | Always. |
2038
+ | `refused(report)` | Always. The host tells the adapter what it would not take -- a snapshot it did not replace its state with, an event it dropped -- with every rule broken, so a refusal is visible on both sides. See **What the host does with what it refuses**. |
2024
2039
  | `setCapacity(capacity)` | Always. Nothing may be allocated until a capacity is stated, so there is no connection that does not receive it. |
2025
2040
  | `execute(request)` | Always. Every channel has commands no capability gates — see **Which commands need a capability**. |
2026
2041
  | `describeUsers(ids)` | The adapter publishes any `UserId`: on `ImposedBreak.by`, a roster, or `handlingHistory[].by`. Each `User` carries its `timeZone` where the provider knows it. |
@@ -2221,7 +2236,8 @@ time. Runtime conformance checks also require the task channel to match its prov
2221
2236
 
2222
2237
  | Field | Contract |
2223
2238
  | --- | --- |
2224
- | `id` | Required `TaskId`, unique within the provider. Omni scopes it with the provider ID. |
2239
+ | `id` | Required `TaskId`, the platform's own identity for the task. Unique among the tasks open at once; a platform retires an id minutes after closing it, so the same id comes back for another customer. Omni scopes it with the provider ID. |
2240
+ | `allocationId` | Required `AllocationId`: this life of the task, minted once per offer and never reused for the life of the login, whatever the id does. Every event, command and report that names a task names its allocation too, so a late dial outcome or a late handling report for the first customer never lands on the next under the same id. The stream refuses an offer reusing one (`stream.taskOffered.allocation`), an update restating another life of the id (`stream.taskUpdated.allocation`), and a media or ending event naming a life that has ended or that nobody has seen (`stream.allocation.ended`, `.unknown`); a `dial-outcome` may name an ended life, since a dial placed late routinely outlives its call, and the host routes it there. See **A task's life on the wire**. |
2225
2241
  | `title` | Agent-facing task title. |
2226
2242
  | `channel` | Channel handling this task. It must equal the source provider's manifest channel. |
2227
2243
  | `taskType` | Required provider-defined source or category of work, such as a voice `Queue Name`, `Mailbox Folder`, `Chat Source`, `Support`, `Billing`, or `Returns`. |
@@ -2287,6 +2303,24 @@ The canonical task transitions are:
2287
2303
  | `completing` | Agent connects back to the party (`connect-back`) | `in-progress` |
2288
2304
  | Any phase | Provider emits `task-ended` | Removed |
2289
2305
 
2306
+ #### A task's life on the wire
2307
+
2308
+ A task id is the platform's, and platforms reuse them: a closed id is retired minutes later, a
2309
+ requeue takes seconds, and on one platform a call was offered twice, thirteen seconds apart, under
2310
+ one id, through a close and a re-offer. Everything that names a task after the fact -- a dial
2311
+ outcome, a media event, an ending, a handling report, a command -- would land on whichever life of
2312
+ the id is open when it arrives. So every offer mints an `allocationId`, unique for the life of the
2313
+ login, and every one of those names it beside the `taskId`. The host keys its state on the pair, a
2314
+ late event finds the life it belongs to or is refused, and a task-scoped browser session
2315
+ (`PROVIDER_NAME__TASK_ID__TAB_NAME`) lives one allocation, never the next customer's cookies under
2316
+ a reused id. A dial the host placed was already safe, since its outcome is placed by the host's own
2317
+ `dialId`; where the allocation earns its place is everything the host does not mint --
2318
+ `task-media-started`, `task-media-ended`, `task-ended`, a `recordStep` naming a task -- any of which
2319
+ would otherwise find the new task under the old id and act on it. The allocation is part of the task
2320
+ the adapter keeps, not a field held in memory beside it: a rebuilt adapter comes back with the same
2321
+ allocation on the same task, or every late event it was minted to catch mismatches
2322
+ (`drive.reload.allocation`).
2323
+
2290
2324
  Allocation, acceptance, and progress are distinct. Acceptance follows `autoAcceptTasks` and the
2291
2325
  task's `acceptance`, moving the task from `pending` to `confirmed`. The provider reports
2292
2326
  subsequent transitions to `preview` or `in-progress`; Omni does not infer them from the acceptance
@@ -2332,9 +2366,10 @@ Reaching the instant is not itself a transition: the provider reports what it di
2332
2366
 
2333
2367
  **Drop both fields when the phase moves.** A provider that builds the `in-progress` task by
2334
2368
  spreading the `preview` one carries `previewEndsAt` and `atDeadline` with it, and the host refuses
2335
- the update (`task.preview.deadline.unexpected`) while the provider's own state looks right: the
2336
- desk hears nothing and the provider sees nothing wrong. The task past preview has no deadline to
2337
- wait for, so it carries neither field.
2369
+ the update (`task.preview.deadline.unexpected`): the desk keeps the task as it last stood, and the
2370
+ provider is told through `refused` exactly which rule, so the state that looked right on its side
2371
+ is named on its side. The task past preview has no deadline to wait for, so it carries neither
2372
+ field.
2338
2373
 
2339
2374
  **A task is never its audio.** A voice task is the allocation: the call is offered when it is
2340
2375
  routed to the agent and accepted as its `acceptance` dictates, and its presence and phase follow
@@ -2603,11 +2638,12 @@ writes it into its record as it writes every other leg:
2603
2638
  ```ts
2604
2639
  declare const connection: Connection<"voice">;
2605
2640
  declare const taskId: TaskId;
2641
+ declare const allocationId: AllocationId;
2606
2642
  declare const at: IsoTimestamp;
2607
2643
 
2608
- void connection.recordStep?.({ taskId, step: "muted", at, mutedBy: "host" }); // the moment the agent mutes
2609
- void connection.recordStep?.({ taskId, step: "muted", at, mutedBy: "host", seconds: 15 }); // still running, if the host chooses to say
2610
- void connection.recordStep?.({ taskId, step: "muted", at, mutedBy: "host", seconds: 42, ended: true }); // the moment they unmute
2644
+ void connection.recordStep?.({ taskId, allocationId, step: "muted", at, mutedBy: "host" }); // the moment the agent mutes
2645
+ void connection.recordStep?.({ taskId, allocationId, step: "muted", at, mutedBy: "host", seconds: 15 }); // still running, if the host chooses to say
2646
+ void connection.recordStep?.({ taskId, allocationId, step: "muted", at, mutedBy: "host", seconds: 42, ended: true }); // the moment they unmute
2611
2647
  ```
2612
2648
 
2613
2649
  The entry is keyed by `step` and `at`, so every report about one leg names the same instant. The
@@ -2724,7 +2760,7 @@ The supported `BrowserIsolationScheme` values, declared under **Shapes**, key as
2724
2760
 
2725
2761
  | Enum member | Example session key |
2726
2762
  | --- | --- |
2727
- | `PROVIDER_NAME__TASK_ID__TAB_NAME` | `mailflow.EMAIL-829102.CRM` |
2763
+ | `PROVIDER_NAME__TASK_ID__TAB_NAME` | `mailflow.EMAIL-829102%2Ea1.CRM` -- the task's allocation, so one life of the id, never the next customer's cookies |
2728
2764
  | `TAB_NAME` | `CRM` |
2729
2765
  | `PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME` | `mailflow.Support.CRM` |
2730
2766
  | `PROVIDER_NAME__TAB_NAME` | `mailflow.CRM` |
@@ -3085,15 +3121,16 @@ across the installation, and it travels four ways so no leg is a lookup:
3085
3121
  ```ts
3086
3122
  declare const connection: Connection<"voice">;
3087
3123
  declare const taskId: TaskId;
3124
+ declare const allocationId: AllocationId;
3088
3125
 
3089
3126
  // 1. Out, minted by the host.
3090
- const result = await connection.execute({ taskId, command: { type: "conference", action: "add", dialId: "dial-7f2", destinationId: "tier2" } });
3127
+ const result = await connection.execute({ taskId, allocationId, command: { type: "conference", action: "add", dialId: "dial-7f2", destinationId: "tier2" } });
3091
3128
 
3092
3129
  // 2. Back on the result, restated, so the host compares and confirms.
3093
3130
  expect(result).toEqual({ status: "dialling", dialId: "dial-7f2" });
3094
3131
 
3095
3132
  // 3. On the outcome, however late, against the task it belonged to.
3096
- const outcome: ProviderEvent<"voice"> = { type: "dial-outcome", dialId: "dial-7f2", taskId, destinationId: "tier2", outcome: "no-answer", reason: "No route to destination" };
3133
+ const outcome: ProviderEvent<"voice"> = { type: "dial-outcome", dialId: "dial-7f2", taskId, allocationId, destinationId: "tier2", outcome: "no-answer", reason: "No route to destination" };
3097
3134
 
3098
3135
  // 4. In the record, so a dial made before a transfer is placeable by whoever holds the task now.
3099
3136
  const step: TaskHandlingStep = { step: "unanswered", at: "2026-08-21T09:15:30Z", by: "A-12", dialId: "dial-7f2", destinationId: "tier2" };
@@ -3769,7 +3806,7 @@ executeTeamMonitor({ command: { type: "monitor", memberId: "A-1" } })
3769
3806
 
3770
3807
  // 2. The lead's own task arrives -- task-offered with `automatic`, as a joined call does -- and
3771
3808
  // carries `monitoring`; the task id is the lead's, the member's call is named inside it.
3772
- // monitoring: { memberId: "A-1", taskId: "call-42", mode: "monitor", since }
3809
+ // monitoring: { memberId: "A-1", taskId: "call-42", allocationId: "alloc-42", mode: "monitor", since }
3773
3810
 
3774
3811
  // 3. The lead changes how they are heard; the provider restates the task with the new mode.
3775
3812
  executeTeamMonitor({ command: { type: "whisper" } })
@@ -3806,7 +3843,7 @@ const monitoringLead = {
3806
3843
  channel: "voice",
3807
3844
  capabilities: {},
3808
3845
  phase: "in-progress",
3809
- monitoring: { memberId: "A-1", taskId: "call-42", mode: "whisper", since: "2026-08-21T09:04:00Z" },
3846
+ monitoring: { memberId: "A-1", taskId: "call-42", allocationId: "alloc-42", mode: "whisper", since: "2026-08-21T09:04:00Z" },
3810
3847
  } satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "monitoring">;
3811
3848
  ```
3812
3849
 
@@ -4438,7 +4475,8 @@ when a lead who joined it leaves -- see **Lead assist**.
4438
4475
  A successful `complete` or `transfer` command does not clear the task. Omni waits for `task-ended`.
4439
4476
  The `task-media-ended` event and the `completing` phase are likewise non-terminal. A replacement
4440
4477
  snapshot that no longer contains the task also clears it. Repeated `task-ended` delivery with the
4441
- same envelope ID is harmless.
4478
+ same envelope ID is harmless, and a `task-ended` naming an allocation that has already ended is
4479
+ recognised as the late event it is, never applied to the life now open under the same id.
4442
4480
 
4443
4481
  ### `dial-outcome`
4444
4482
 
@@ -4475,7 +4513,7 @@ fails loudly rather than passing with a note. A task published under `capability
4475
4513
  | --- | --- |
4476
4514
  | `expected` | Required. The rule that was broken, as a sentence a person can read. |
4477
4515
  | `observed` | Required. What the platform answered instead. |
4478
- | `taskId` | Optional. The task concerned, where there is one. |
4516
+ | `taskId` | Optional. The task concerned, where there is one, with its `allocationId` beside it: a task is named with its life, and an allocation never alone. |
4479
4517
 
4480
4518
  ### `queue-summary`
4481
4519
 
@@ -4576,6 +4614,25 @@ and may be compiled against a different protocol version, so its output is untru
4576
4614
  Validating a snapshot before it replaces provider state is what stops a malformed task from
4577
4615
  reaching the workspace.
4578
4616
 
4617
+ ### What the host does with what it refuses
4618
+
4619
+ A refusal has an aftermath on the desk and a report to the provider, and both are stated.
4620
+
4621
+ - **A refused snapshot replaces nothing.** The host keeps the last state it took from this
4622
+ provider whole, and shows the provider as faulted -- the transport as it stands, and the words
4623
+ "last update refused" with the rule -- so the agent knows the view is standing still rather than
4624
+ believing it current. It does not adopt the good half of a snapshot: a task the agent is on would
4625
+ vanish from the desk while the call is up.
4626
+ - **A refused event is dropped and counted.** The state the host holds does not move for it. The
4627
+ provider is told, and may republish a corrected state or raise a `diagnostic`; the host retries
4628
+ nothing.
4629
+ - **The provider is told, every time.** `refused(report)` carries the artefact, the envelope id for
4630
+ an event, and every violation with its rule and path. An adapter logs it at error and treats it as
4631
+ its own defect until shown otherwise: what the host refused never reached the agent, and a
4632
+ provider that hears nothing runs a whole shift beside a frozen desk with nothing wrong on its
4633
+ side. The harness tells an adapter under test exactly as a host does
4634
+ (`connection.refused.required`, `connection.refused.rejected`).
4635
+
4579
4636
  ## Conformance helpers
4580
4637
 
4581
4638
  ### `exerciseAdapter(adapter, context, options?)`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",