@xema/omni-protocol 0.1.67 → 0.1.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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;
@@ -1603,6 +1623,8 @@ export interface BrowserSessionKeyInput {
1603
1623
  /** `Manifest.id`, never `displayName`: only the id is unique across an installation and stable. */
1604
1624
  providerId: string;
1605
1625
  taskId: TaskId;
1626
+ /** The task's allocation: a task-scoped session lives one life of the task, never the next customer's under a reused id. */
1627
+ allocationId: AllocationId;
1606
1628
  /** `Task.taskType`. */
1607
1629
  taskType: string;
1608
1630
  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
@@ -305,6 +305,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
305
305
  requireMethod(live, "recordStep", "the login is on a softphone, whose microphone the host mutes");
306
306
  const eventPayloads = new Map();
307
307
  let capacityStated;
308
+ const reloading = { now: false };
308
309
  // The drive waits on events: each waiter is offered every envelope as it lands.
309
310
  const waiters = new Set();
310
311
  unsubscribe = connection.subscribe(envelope => {
@@ -312,7 +313,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
312
313
  violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader()));
313
314
  // A diagnostic is informational to a host and a failure to a conformance run: the platform
314
315
  // 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") {
316
+ // A reload in life has no first connection: the page is gone. The drive keeps its first
317
+ // connection up while the second reads, so a platform that pushes the open task to the new
318
+ // client pushes it to the old one too, and an adapter that calls a re-offer of answered work a
319
+ // defect is right to. That diagnostic is the drive's own artefact, and is not counted while the
320
+ // second adapter is up; every other moment it is.
321
+ if (isRecord(envelope?.event) && envelope.event.type === "diagnostic" && !reloading.now) {
316
322
  violations.push({ rule: "diagnostic.raised", path: "event.diagnostic",
317
323
  message: `the provider reported a diagnostic: expected ${String(envelope.event.expected)}; observed ${String(envelope.event.observed)}` });
318
324
  }
@@ -422,7 +428,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
422
428
  violations.push(...await driveOneCall({
423
429
  connection: live, manifest: adapter.manifest, channel: adapter.manifest.channel, softphone, snapshot, events, waiters, stream, localAudio,
424
430
  timeoutMs: options.driveTimeoutMs ?? 5000,
425
- context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held,
431
+ context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, reloading,
426
432
  }));
427
433
  }
428
434
  }
@@ -817,6 +823,11 @@ const REACHABLE_PHASES = {
817
823
  };
818
824
  export class TaskStream {
819
825
  tasks = new Map();
826
+ // Every allocation this login has seen, and the ones whose task has ended: an offer never reuses
827
+ // one, an event about a task names the life that is open, and a late event for a life that ended
828
+ // is recognised as that rather than landing on the next customer under the same id.
829
+ allocations = new Set();
830
+ endedAllocations = new Set();
820
831
  // Every dial the stream can place an outcome against: one the host said it placed, or one a
821
832
  // task carried on `onCall` or in its record -- which is how a dial made before a transfer is known
822
833
  // to whoever holds the task now. `answered` or `ended` once its outcome arrived, since it comes once.
@@ -838,6 +849,23 @@ export class TaskStream {
838
849
  this.dialled(entry.dialId);
839
850
  }
840
851
  }
852
+ /**
853
+ * Whether an event about a task names the life that is open under its id. One naming a life that
854
+ * has ended is the late event the allocation exists to catch; one naming a life nobody has seen is
855
+ * a different fault. Either way it is not applied to the open task.
856
+ */
857
+ namesTheOpenLife(event, known, at, refuse) {
858
+ const named = event.allocationId;
859
+ if (typeof named !== "string" || named === known.allocation)
860
+ return true;
861
+ if (this.endedAllocations.has(named)) {
862
+ refuse("stream.allocation.ended", `${at}.allocationId`, `${String(event.type)} names allocation ${named}, a life of ${String(event.taskId)} that has ended; the life open under that id is ${known.allocation}, and a late event lands on the life it names, never the next`);
863
+ }
864
+ else {
865
+ refuse("stream.allocation.unknown", `${at}.allocationId`, `${named} is not an allocation this login has seen`);
866
+ }
867
+ return false;
868
+ }
841
869
  /** How many tasks the stream currently holds open. */
842
870
  openCount() { return this.tasks.size; }
843
871
  /** Whether an offered task is a dial arriving -- the host's own call, a connect-back -- which counts against no capacity: an entry on its call carrying a dial. */
@@ -876,7 +904,7 @@ export class TaskStream {
876
904
  stages.set(entry.dialId, entry.stage);
877
905
  }
878
906
  }
879
- return { phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task) };
907
+ return { phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task), allocation: String(isRecord(task) ? task.allocationId : undefined) };
880
908
  }
881
909
  /** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
882
910
  seed(snapshot) {
@@ -884,8 +912,11 @@ export class TaskStream {
884
912
  if (!isRecord(snapshot) || !Array.isArray(snapshot.tasks))
885
913
  return;
886
914
  for (const task of snapshot.tasks) {
887
- if (isRecord(task) && typeof task.id === "string")
915
+ if (isRecord(task) && typeof task.id === "string") {
888
916
  this.tasks.set(task.id, TaskStream.stated(task));
917
+ if (typeof task.allocationId === "string")
918
+ this.allocations.add(task.allocationId);
919
+ }
889
920
  this.noteDials(task);
890
921
  }
891
922
  }
@@ -920,19 +951,27 @@ export class TaskStream {
920
951
  }
921
952
  this.seed(event.snapshot);
922
953
  break;
923
- case "task-offered":
924
- ruleEvaluated("stream.taskOffered.duplicate");
954
+ case "task-offered": {
955
+ ruleEvaluated("stream.taskOffered.duplicate", "stream.taskOffered.allocation");
925
956
  if (id === undefined)
926
957
  break;
927
958
  if (known !== undefined)
928
959
  refuse("stream.taskOffered.duplicate", `${at}.task.id`, `${id} is already on the stream; an offer introduces a task once`);
960
+ const allocation = isRecord(event.task) ? event.task.allocationId : undefined;
961
+ if (typeof allocation === "string") {
962
+ if (this.allocations.has(allocation)) {
963
+ refuse("stream.taskOffered.allocation", `${at}.task.allocationId`, `${allocation} was already an allocation on this login: an allocation is minted once per offer and never reused, whatever the task id does`);
964
+ }
965
+ this.allocations.add(allocation);
966
+ }
929
967
  this.tasks.set(id, TaskStream.stated(event.task));
930
968
  this.noteDials(event.task);
931
969
  break;
970
+ }
932
971
  case "task-updated":
933
972
  if (id === undefined)
934
973
  break;
935
- ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
974
+ ruleEvaluated("stream.taskUpdated.unknown", "stream.taskUpdated.capabilitySource", "stream.taskUpdated.phase", "stream.taskUpdated.allocation", "stream.taskUpdated.handlingHistory", "stream.taskMediaEnded.follow", "stream.taskUpdated.media", "stream.taskUpdated.stage");
936
975
  if (known === undefined) {
937
976
  refuse("stream.taskUpdated.unknown", `${at}.task.id`, `${id} was never offered or carried on a snapshot`);
938
977
  break;
@@ -943,6 +982,11 @@ export class TaskStream {
943
982
  if ((known.source === "queue" || known.source === "ungoverned") && isRecord(event.task) && event.task.capabilitySource === "undetermined") {
944
983
  refuse("stream.taskUpdated.capabilitySource", `${at}.task.capabilitySource`, `${id} was published under ${known.source} terms and now says undetermined: terms once read stay read, and a re-read that fails is a diagnostic, not a republish`);
945
984
  }
985
+ // An update is of the life that is open. One carrying another allocation is a copy of a
986
+ // different life of this id -- a stale republish of the last customer's call, or the next one's.
987
+ if (isRecord(event.task) && String(event.task.allocationId) !== known.allocation) {
988
+ refuse("stream.taskUpdated.allocation", `${at}.task.allocationId`, `${id} is open as allocation ${known.allocation} and the update says ${String(event.task.allocationId)}: an update restates the life that is open, never another`);
989
+ }
946
990
  // A task does not go backwards. The stream sees publications, not transitions, and a task may
947
991
  // pass through a phase between two, so what is refused is a phase unreachable from the last
948
992
  // one read by the guide's table: back to pending, back to confirmed once work began, out of
@@ -993,13 +1037,15 @@ export class TaskStream {
993
1037
  this.noteDials(event.task);
994
1038
  break;
995
1039
  case "task-media-started":
996
- ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate");
1040
+ ruleEvaluated("stream.taskMediaStarted.unknown", "stream.taskMediaStarted.beforeWork", "stream.taskMediaStarted.duplicate", "stream.allocation.ended", "stream.allocation.unknown");
997
1041
  if (id === undefined)
998
1042
  break;
999
1043
  if (known === undefined) {
1000
1044
  refuse("stream.taskMediaStarted.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
1001
1045
  break;
1002
1046
  }
1047
+ if (!this.namesTheOpenLife(event, known, at, refuse))
1048
+ break;
1003
1049
  if (!AT_WORK.has(known.phase)) {
1004
1050
  refuse("stream.taskMediaStarted.beforeWork", `${at}.taskId`, `media cannot arrive on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
1005
1051
  }
@@ -1009,13 +1055,15 @@ export class TaskStream {
1009
1055
  known.media = "started";
1010
1056
  break;
1011
1057
  case "task-media-ended":
1012
- ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent");
1058
+ ruleEvaluated("stream.taskMediaEnded.unknown", "stream.taskMediaEnded.beforeWork", "stream.taskMediaEnded.silent", "stream.allocation.ended", "stream.allocation.unknown");
1013
1059
  if (id === undefined)
1014
1060
  break;
1015
1061
  if (known === undefined) {
1016
1062
  refuse("stream.taskMediaEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
1017
1063
  break;
1018
1064
  }
1065
+ if (!this.namesTheOpenLife(event, known, at, refuse))
1066
+ break;
1019
1067
  if (!WORK_BEGUN.has(known.phase)) {
1020
1068
  refuse("stream.taskMediaEnded.beforeWork", `${at}.taskId`, `media cannot end on ${id} while it is ${known.phase}: a task is never its audio, and its work has not begun`);
1021
1069
  }
@@ -1025,15 +1073,27 @@ export class TaskStream {
1025
1073
  known.media = "ended";
1026
1074
  break;
1027
1075
  case "task-ended":
1076
+ ruleEvaluated("stream.taskEnded.unknown", "stream.allocation.ended", "stream.allocation.unknown");
1028
1077
  if (id === undefined)
1029
1078
  break;
1030
- if (known === undefined)
1079
+ if (known === undefined) {
1031
1080
  refuse("stream.taskEnded.unknown", `${at}.taskId`, `${id} was never offered or carried on a snapshot`);
1081
+ break;
1082
+ }
1083
+ // A late ending for a life that is over must not end the life that is open under the same id.
1084
+ if (!this.namesTheOpenLife(event, known, at, refuse))
1085
+ break;
1086
+ this.endedAllocations.add(known.allocation);
1032
1087
  this.tasks.delete(id);
1033
1088
  break;
1034
1089
  case "dial-outcome": {
1035
1090
  // 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.
1091
+ // a dial placed late routinely outlives its call, which is why the dial has its own identity,
1092
+ // and why the outcome names the allocation: the host routes it to that life, ended or not.
1093
+ ruleEvaluated("stream.allocation.unknown");
1094
+ if (typeof event.allocationId === "string" && !this.allocations.has(event.allocationId)) {
1095
+ refuse("stream.allocation.unknown", `${at}.allocationId`, `${event.allocationId} is not an allocation this login has seen`);
1096
+ }
1037
1097
  if (typeof event.dialId !== "string")
1038
1098
  break;
1039
1099
  const dial = this.dials.get(event.dialId);
@@ -1088,6 +1148,7 @@ async function driveOneCall(drive) {
1088
1148
  drive.waiters.add(waiter);
1089
1149
  });
1090
1150
  const recordSurvivesReload = async (at) => {
1151
+ ruleEvaluated("drive.reload.rejected", "drive.reload.manifest", "drive.reload.login", "drive.reload.snapshot", "drive.reload.allocation", "drive.reload.history");
1091
1152
  let again;
1092
1153
  try {
1093
1154
  again = drive.rebuild();
@@ -1104,6 +1165,7 @@ async function driveOneCall(drive) {
1104
1165
  refuse("drive.reload.manifest", "drive.reload.manifest", `rebuild returned an adapter for ${String(again.manifest.id)}; the reload is of ${String(drive.manifest.id)}`);
1105
1166
  return;
1106
1167
  }
1168
+ drive.reloading.now = true;
1107
1169
  session = await again.createAuthenticationSession({ ...drive.context, secrets: drive.secrets });
1108
1170
  // The reload is a restore before it is anything else: the second session stands authenticated
1109
1171
  // as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
@@ -1124,6 +1186,10 @@ async function driveOneCall(drive) {
1124
1186
  refuse("drive.reload.snapshot", "drive.reload.snapshot", `a second adapter built from the same login does not carry ${taskId} on its snapshot, and the task is still open`);
1125
1187
  return;
1126
1188
  }
1189
+ // The allocation is part of the task, not memory beside it: a reload brings the same life back.
1190
+ if (carried.allocationId !== allocationOf()) {
1191
+ refuse("drive.reload.allocation", "drive.reload.allocation", `a second adapter built from the same login carries ${taskId} as allocation ${String(carried.allocationId)}; the first published ${allocationOf()}, and a life does not change its name on a reload`);
1192
+ }
1127
1193
  const history = carried.handlingHistory;
1128
1194
  const steps = isRecord(history) && Array.isArray(history.steps) ? history.steps : [];
1129
1195
  // A record once read is not unread across a reload either: every entry the first adapter published is here.
@@ -1158,6 +1224,7 @@ async function driveOneCall(drive) {
1158
1224
  catch (error) {
1159
1225
  refuse("drive.reload.rejected", "drive.reload", `the second adapter's session threw on close: ${String(error)}`);
1160
1226
  }
1227
+ drive.reloading.now = false;
1161
1228
  }
1162
1229
  };
1163
1230
  // A task's keys are gone with the task. A task-scoped key carries the task id, so the watched
@@ -1165,6 +1232,7 @@ async function driveOneCall(drive) {
1165
1232
  // inherited by the next offer of the same id, since a platform retires an id minutes after closing it.
1166
1233
  let retainedAtEnd;
1167
1234
  const storeReleased = async () => {
1235
+ ruleEvaluated("drive.store.retained");
1168
1236
  await Promise.resolve();
1169
1237
  await Promise.resolve();
1170
1238
  const retained = [...drive.held].filter(key => key.includes(taskId));
@@ -1178,6 +1246,7 @@ async function driveOneCall(drive) {
1178
1246
  const nothingLate = () => {
1179
1247
  if (retainedAtEnd === undefined)
1180
1248
  return;
1249
+ ruleEvaluated("drive.store.late");
1181
1250
  const late = [...drive.held].filter(key => key.includes(taskId) && !retainedAtEnd.has(key));
1182
1251
  if (late.length > 0) {
1183
1252
  refuse("drive.store.late", "drive.store", `${taskId} had ended with its keys gone, and the login's store now holds ${late.join(", ")}: something wrote about the task after its end`);
@@ -1197,6 +1266,7 @@ async function driveOneCall(drive) {
1197
1266
  cursor = offered.at;
1198
1267
  }
1199
1268
  const taskId = task.id;
1269
+ const allocationOf = () => String(latestTask().allocationId);
1200
1270
  const latestTask = () => task;
1201
1271
  const updated = (until, what) => waitFor(what, envelope => {
1202
1272
  const event = envelope.event;
@@ -1212,14 +1282,16 @@ async function driveOneCall(drive) {
1212
1282
  return event.type === "task-ended" && event.taskId === taskId ? event : undefined;
1213
1283
  }, cursor);
1214
1284
  // Every command the drive sends is validated against the task as published, and its answer for its method.
1285
+ ruleEvaluated("drive.timeout");
1215
1286
  const send = async (command, dialId) => {
1287
+ ruleEvaluated("drive.command.rejected", "drive.command.failed");
1216
1288
  const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`);
1217
1289
  found.push(...own);
1218
1290
  if (own.length > 0)
1219
1291
  return undefined;
1220
1292
  let result;
1221
1293
  try {
1222
- result = await drive.connection.execute({ taskId, command });
1294
+ result = await drive.connection.execute({ taskId, allocationId: allocationOf(), command });
1223
1295
  }
1224
1296
  catch (error) {
1225
1297
  refuse("drive.command.rejected", `drive.command.${String(command.type)}`, `execute rejected rather than answered: ${String(error)}`);
@@ -1261,10 +1333,11 @@ async function driveOneCall(drive) {
1261
1333
  // control still declared: a host holds it back (command.phase.handling), and an adapter that
1262
1334
  // receives it anyway must refuse it -- so the drive sends it past the validator and expects failed.
1263
1335
  const holdRefusedOutsideHandling = async () => {
1336
+ ruleEvaluated("drive.command.rejected", "drive.command.handling");
1264
1337
  const phase = String(latestTask().phase);
1265
1338
  let answer;
1266
1339
  try {
1267
- answer = await drive.connection.execute({ taskId, command: { type: "hold" } });
1340
+ answer = await drive.connection.execute({ taskId, allocationId: allocationOf(), command: { type: "hold" } });
1268
1341
  }
1269
1342
  catch (error) {
1270
1343
  refuse("drive.command.rejected", "drive.command.hold", `execute rejected rather than answered: ${String(error)}`);
@@ -1291,7 +1364,7 @@ async function driveOneCall(drive) {
1291
1364
  return found;
1292
1365
  cursor = Math.max(cursor, started.at);
1293
1366
  }
1294
- const opened = await drive.connection.openMedia?.({ taskId, localAudio: drive.localAudio });
1367
+ const opened = await drive.connection.openMedia?.({ taskId, allocationId: allocationOf(), localAudio: drive.localAudio });
1295
1368
  found.push(...validateResult(opened, "openMedia", "drive.openMedia"));
1296
1369
  if (isRecord(opened) && opened.status === "opened")
1297
1370
  session = opened.session;
@@ -1305,8 +1378,9 @@ async function driveOneCall(drive) {
1305
1378
  if (session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function") {
1306
1379
  const at = new Date().toISOString();
1307
1380
  const report = async (body) => {
1381
+ ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
1308
1382
  // 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 };
1383
+ const leg = { taskId, allocationId: allocationOf(), step: "muted", at, mutedBy: "host", ...body };
1310
1384
  const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
1311
1385
  found.push(...own);
1312
1386
  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[] };
@@ -2221,7 +2228,8 @@ time. Runtime conformance checks also require the task channel to match its prov
2221
2228
 
2222
2229
  | Field | Contract |
2223
2230
  | --- | --- |
2224
- | `id` | Required `TaskId`, unique within the provider. Omni scopes it with the provider ID. |
2231
+ | `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. |
2232
+ | `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
2233
  | `title` | Agent-facing task title. |
2226
2234
  | `channel` | Channel handling this task. It must equal the source provider's manifest channel. |
2227
2235
  | `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 +2295,24 @@ The canonical task transitions are:
2287
2295
  | `completing` | Agent connects back to the party (`connect-back`) | `in-progress` |
2288
2296
  | Any phase | Provider emits `task-ended` | Removed |
2289
2297
 
2298
+ #### A task's life on the wire
2299
+
2300
+ A task id is the platform's, and platforms reuse them: a closed id is retired minutes later, a
2301
+ requeue takes seconds, and on one platform a call was offered twice, thirteen seconds apart, under
2302
+ one id, through a close and a re-offer. Everything that names a task after the fact -- a dial
2303
+ outcome, a media event, an ending, a handling report, a command -- would land on whichever life of
2304
+ the id is open when it arrives. So every offer mints an `allocationId`, unique for the life of the
2305
+ login, and every one of those names it beside the `taskId`. The host keys its state on the pair, a
2306
+ late event finds the life it belongs to or is refused, and a task-scoped browser session
2307
+ (`PROVIDER_NAME__TASK_ID__TAB_NAME`) lives one allocation, never the next customer's cookies under
2308
+ a reused id. A dial the host placed was already safe, since its outcome is placed by the host's own
2309
+ `dialId`; where the allocation earns its place is everything the host does not mint --
2310
+ `task-media-started`, `task-media-ended`, `task-ended`, a `recordStep` naming a task -- any of which
2311
+ would otherwise find the new task under the old id and act on it. The allocation is part of the task
2312
+ the adapter keeps, not a field held in memory beside it: a rebuilt adapter comes back with the same
2313
+ allocation on the same task, or every late event it was minted to catch mismatches
2314
+ (`drive.reload.allocation`).
2315
+
2290
2316
  Allocation, acceptance, and progress are distinct. Acceptance follows `autoAcceptTasks` and the
2291
2317
  task's `acceptance`, moving the task from `pending` to `confirmed`. The provider reports
2292
2318
  subsequent transitions to `preview` or `in-progress`; Omni does not infer them from the acceptance
@@ -2603,11 +2629,12 @@ writes it into its record as it writes every other leg:
2603
2629
  ```ts
2604
2630
  declare const connection: Connection<"voice">;
2605
2631
  declare const taskId: TaskId;
2632
+ declare const allocationId: AllocationId;
2606
2633
  declare const at: IsoTimestamp;
2607
2634
 
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
2635
+ void connection.recordStep?.({ taskId, allocationId, step: "muted", at, mutedBy: "host" }); // the moment the agent mutes
2636
+ void connection.recordStep?.({ taskId, allocationId, step: "muted", at, mutedBy: "host", seconds: 15 }); // still running, if the host chooses to say
2637
+ void connection.recordStep?.({ taskId, allocationId, step: "muted", at, mutedBy: "host", seconds: 42, ended: true }); // the moment they unmute
2611
2638
  ```
2612
2639
 
2613
2640
  The entry is keyed by `step` and `at`, so every report about one leg names the same instant. The
@@ -2724,7 +2751,7 @@ The supported `BrowserIsolationScheme` values, declared under **Shapes**, key as
2724
2751
 
2725
2752
  | Enum member | Example session key |
2726
2753
  | --- | --- |
2727
- | `PROVIDER_NAME__TASK_ID__TAB_NAME` | `mailflow.EMAIL-829102.CRM` |
2754
+ | `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
2755
  | `TAB_NAME` | `CRM` |
2729
2756
  | `PROVIDER_NAME__TASK_TYPE_NAME__TAB_NAME` | `mailflow.Support.CRM` |
2730
2757
  | `PROVIDER_NAME__TAB_NAME` | `mailflow.CRM` |
@@ -3085,15 +3112,16 @@ across the installation, and it travels four ways so no leg is a lookup:
3085
3112
  ```ts
3086
3113
  declare const connection: Connection<"voice">;
3087
3114
  declare const taskId: TaskId;
3115
+ declare const allocationId: AllocationId;
3088
3116
 
3089
3117
  // 1. Out, minted by the host.
3090
- const result = await connection.execute({ taskId, command: { type: "conference", action: "add", dialId: "dial-7f2", destinationId: "tier2" } });
3118
+ const result = await connection.execute({ taskId, allocationId, command: { type: "conference", action: "add", dialId: "dial-7f2", destinationId: "tier2" } });
3091
3119
 
3092
3120
  // 2. Back on the result, restated, so the host compares and confirms.
3093
3121
  expect(result).toEqual({ status: "dialling", dialId: "dial-7f2" });
3094
3122
 
3095
3123
  // 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" };
3124
+ const outcome: ProviderEvent<"voice"> = { type: "dial-outcome", dialId: "dial-7f2", taskId, allocationId, destinationId: "tier2", outcome: "no-answer", reason: "No route to destination" };
3097
3125
 
3098
3126
  // 4. In the record, so a dial made before a transfer is placeable by whoever holds the task now.
3099
3127
  const step: TaskHandlingStep = { step: "unanswered", at: "2026-08-21T09:15:30Z", by: "A-12", dialId: "dial-7f2", destinationId: "tier2" };
@@ -3769,7 +3797,7 @@ executeTeamMonitor({ command: { type: "monitor", memberId: "A-1" } })
3769
3797
 
3770
3798
  // 2. The lead's own task arrives -- task-offered with `automatic`, as a joined call does -- and
3771
3799
  // 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 }
3800
+ // monitoring: { memberId: "A-1", taskId: "call-42", allocationId: "alloc-42", mode: "monitor", since }
3773
3801
 
3774
3802
  // 3. The lead changes how they are heard; the provider restates the task with the new mode.
3775
3803
  executeTeamMonitor({ command: { type: "whisper" } })
@@ -3806,7 +3834,7 @@ const monitoringLead = {
3806
3834
  channel: "voice",
3807
3835
  capabilities: {},
3808
3836
  phase: "in-progress",
3809
- monitoring: { memberId: "A-1", taskId: "call-42", mode: "whisper", since: "2026-08-21T09:04:00Z" },
3837
+ monitoring: { memberId: "A-1", taskId: "call-42", allocationId: "alloc-42", mode: "whisper", since: "2026-08-21T09:04:00Z" },
3810
3838
  } satisfies Pick<Task<"voice">, "channel" | "capabilities" | "phase" | "monitoring">;
3811
3839
  ```
3812
3840
 
@@ -4438,7 +4466,8 @@ when a lead who joined it leaves -- see **Lead assist**.
4438
4466
  A successful `complete` or `transfer` command does not clear the task. Omni waits for `task-ended`.
4439
4467
  The `task-media-ended` event and the `completing` phase are likewise non-terminal. A replacement
4440
4468
  snapshot that no longer contains the task also clears it. Repeated `task-ended` delivery with the
4441
- same envelope ID is harmless.
4469
+ same envelope ID is harmless, and a `task-ended` naming an allocation that has already ended is
4470
+ recognised as the late event it is, never applied to the life now open under the same id.
4442
4471
 
4443
4472
  ### `dial-outcome`
4444
4473
 
@@ -4475,7 +4504,7 @@ fails loudly rather than passing with a note. A task published under `capability
4475
4504
  | --- | --- |
4476
4505
  | `expected` | Required. The rule that was broken, as a sentence a person can read. |
4477
4506
  | `observed` | Required. What the platform answered instead. |
4478
- | `taskId` | Optional. The task concerned, where there is one. |
4507
+ | `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
4508
 
4480
4509
  ### `queue-summary`
4481
4510
 
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.68",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",