@xema/omni-protocol 0.1.103 → 0.1.105

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
@@ -153,6 +153,12 @@ export interface Manifest<C extends Channel = Channel> {
153
153
  authenticationMethods: AuthenticationMethod[];
154
154
  idleCapabilities?: IdleCapabilities<C>;
155
155
  /** Optional provider-clock sampling through Connection.checkTime; absent means unsupported. */
156
+ /**
157
+ * Implements `checkTime`, and states `providerTime` on every snapshot. Required of a provider
158
+ * that publishes an instant the desk renders as a running duration -- a member's `since`, a
159
+ * lead's `listening.since`, an `onCall.since`, a shift's `signedInAt` -- so every screen counts
160
+ * from the provider's clock and two screens differ by their delay alone.
161
+ */
156
162
  timeCheck?: true;
157
163
  /** Provider uses its own timestamps for final records; host timestamps are advisory. Omission makes no trust promise. */
158
164
  timestampAuthority?: "provider";
@@ -583,7 +589,8 @@ export type TransportStatus = "connecting" | "active" | "error";
583
589
  * connection and call `connect()` again -- or `reauthenticate` -- run the authentication flow
584
590
  * first. The adapter knows which; the host acts on its word.
585
591
  */
586
- export type TransportRecovery = "reconnect" | "reauthenticate";
592
+ /** What revives an errored connection: `displaced` says another client took the login, and nothing revives this one until the agent takes it back. */
593
+ export type TransportRecovery = "reconnect" | "reauthenticate" | "displaced";
587
594
  /** Every field is optional: a provider sends what it knows and omits what it does not. */
588
595
  export interface Contact {
589
596
  name?: string;
@@ -967,6 +974,14 @@ export type Task<C extends Channel = Channel> = {
967
974
  */
968
975
  previewEndsInSeconds?: DurationSeconds;
969
976
  atDeadline?: PreviewDeadline;
977
+ /**
978
+ * In `completing`, wherever `wrapAllowance` is stated: how much of the wrap is left, in whole
979
+ * seconds from this publication, restated on every publication that carries it, so a reloaded
980
+ * desk and a lead's screen count down the same number. Under `provider-automatic` the provider
981
+ * ends the task when it reaches zero; under `agent-command` it is the expected wrap left, `0`
982
+ * once overrun, and nothing acts on it.
983
+ */
984
+ wrapEndsInSeconds?: DurationSeconds;
970
985
  /** The identifier an agent reads back to a customer, where the provider has one. */
971
986
  reference?: string;
972
987
  attributes?: TaskAttribute[];
@@ -1206,8 +1221,13 @@ export interface BreakRequest {
1206
1221
  */
1207
1222
  export type ForcedBreak = {
1208
1223
  by: UserId | "provider";
1209
- /** Optional expected time on break, in milliseconds from actual start. Advisory only. */
1210
- expectedDurationMs?: number;
1224
+ /**
1225
+ * How long the break is expected to run yet, in whole seconds from this publication, restated
1226
+ * on every publication that carries it so every screen counts down the same number from
1227
+ * receipt. Advisory only: nothing ends the break but the agent. Absent once it has run out, or
1228
+ * where none was expected.
1229
+ */
1230
+ expectedEndsInSeconds?: DurationSeconds;
1211
1231
  };
1212
1232
  export interface BreakState {
1213
1233
  status: BreakStatus;
@@ -1264,10 +1284,10 @@ export interface TeamMember {
1264
1284
  break?: Extract<BreakStatus, "awaiting-approval" | "granted" | "starting-after-task">;
1265
1285
  /** The member's open tasks, trimmed, kept current by the provider; `[]` when they hold none, omitted only where the provider cannot see them. */
1266
1286
  tasks?: MemberTask[];
1267
- /** Present while this lead is on the member's call, listening, coaching or joined; absent otherwise. */
1268
- listening?: MemberListening;
1269
- /** The member's own history for the day: sign-in, sign-out, breaks and productivity. Omitted where the provider cannot say. */
1270
- shift?: MemberShift;
1287
+ /** Every lead on this member's call, listening, coaching or joined, published to every lead alike; absent while none is. */
1288
+ listening?: MemberListening[];
1289
+ /** The member's own history for the day: sign-in, sign-out, breaks and productivity, the same numbers the member's own snapshot carries. Omitted where the provider cannot say. */
1290
+ shift?: Shift;
1271
1291
  /** The member's standing ask for a lead on one of their calls; absent when they are not asking. */
1272
1292
  request?: MemberRequest;
1273
1293
  }
@@ -1278,10 +1298,11 @@ export interface MemberRequest {
1278
1298
  since: IsoTimestamp;
1279
1299
  }
1280
1300
  /**
1281
- * A member's task as the lead sees it: the same task the member's desk holds, trimmed by the
1282
- * provider. The assignment, what the call is and where it stands, the room and the full history
1283
- * are always here; the workspace -- browsers, controls, completion terms -- is the member's and
1284
- * travels only if the provider chooses to send it.
1301
+ * A member's task as the lead sees it: the same task the member's desk holds, less the workspace.
1302
+ * The assignment, what the call is and where it stands, the party as the provider shows it to
1303
+ * leads, the room, the audio, the completion terms and the whole record are here; the controls,
1304
+ * their source and the browsers are the member's desk's alone and never travel. Published to every
1305
+ * lead whenever the member's own desk hears of the task.
1285
1306
  */
1286
1307
  export type MemberTask<C extends Channel = Channel> = {
1287
1308
  assignmentId: AssignmentId;
@@ -1293,11 +1314,9 @@ export type MemberTask<C extends Channel = Channel> = {
1293
1314
  reference?: string;
1294
1315
  attributes?: TaskAttribute[];
1295
1316
  history?: TaskHistory;
1296
- capabilities?: TaskCapabilities<C>;
1297
- capabilitySource?: CapabilitySource;
1298
- browsers?: TaskBrowser[];
1299
1317
  completionMode?: CompletionMode;
1300
1318
  wrapAllowance?: DurationSeconds;
1319
+ wrapEndsInSeconds?: DurationSeconds;
1301
1320
  } & (C extends "voice" ? {
1302
1321
  onCall?: OnCall[];
1303
1322
  leadAssist?: TaskLeadAssist;
@@ -1310,11 +1329,14 @@ export type MemberTask<C extends Channel = Channel> = {
1310
1329
  audio?: never;
1311
1330
  });
1312
1331
  /**
1313
- * The lead on this member's call: which of the member's calls, in the mode they are heard, since
1314
- * when. Its appearance on the team member list is the provider's word that the lead's audio is up,
1315
- * and the application opens `openAudio` on the assignment named; its disappearance closes it.
1332
+ * A lead on this member's call: who, which of the member's calls, in the mode they are heard,
1333
+ * since when. Every lead sees every entry; the application opens `openAudio` on the entry whose
1334
+ * `leadId` is the signed-in lead, and closes it when that entry goes. A lead who is heard --
1335
+ * `join-call` -- is also on the task's `onCall` as an `agent`; `listen` and `coach` leave no trace
1336
+ * on the member's task.
1316
1337
  */
1317
1338
  export interface MemberListening {
1339
+ leadId: UserId;
1318
1340
  assignmentId: AssignmentId;
1319
1341
  mode: ListeningMode;
1320
1342
  since: IsoTimestamp;
@@ -1326,11 +1348,13 @@ export interface ShiftEvent {
1326
1348
  kind: ShiftEventKind;
1327
1349
  }
1328
1350
  /**
1329
- * The member's own history for the day, about the person rather than any one call: when they
1351
+ * An agent's own history for the day, about the person rather than any one call: when they
1330
1352
  * signed in and out, today's totals as the provider counts them, each present only when the
1331
- * provider knows it, and the day's sign-in, sign-out and break events, oldest first.
1353
+ * provider knows it, and the day's sign-in, sign-out and break events, oldest first. Published
1354
+ * to the agent on their snapshot and on `shift-updated`, and to their lead on the team member
1355
+ * list: the same numbers from the same count.
1332
1356
  */
1333
- export interface MemberShift {
1357
+ export interface Shift {
1334
1358
  signedInAt: IsoTimestamp;
1335
1359
  signedOutAt?: IsoTimestamp;
1336
1360
  talkSeconds?: DurationSeconds;
@@ -1550,6 +1574,12 @@ export type PreferenceResult = {
1550
1574
  export interface Snapshot<C extends Channel = Channel> {
1551
1575
  transport: TransportStatus;
1552
1576
  loginId: string;
1577
+ /**
1578
+ * The provider's own instant of this read, required of a provider that declares `timeCheck`:
1579
+ * every duration the desk renders off the active call is counted from the provider's clock, and
1580
+ * this sample saves a `checkTime` for it. Every envelope's `occurredAt` is a sample too.
1581
+ */
1582
+ providerTime?: IsoTimestamp;
1553
1583
  break: BreakState;
1554
1584
  /** Every task currently owned by this agent for this provider. */
1555
1585
  tasks: Task<C>[];
@@ -1557,6 +1587,8 @@ export interface Snapshot<C extends Channel = Channel> {
1557
1587
  taskCount: number;
1558
1588
  contacts?: Contact[];
1559
1589
  calendar?: ScheduledActivity[];
1590
+ /** The agent's own day so far, as the provider counts it; replaced whole by `shift-updated`. Omitted where the provider cannot say. */
1591
+ shift?: Shift;
1560
1592
  team?: TeamMembers;
1561
1593
  }
1562
1594
  /**
@@ -1668,6 +1700,11 @@ export type ProviderEvent<C extends Channel = Channel> = {
1668
1700
  } | {
1669
1701
  type: "calendar-updated";
1670
1702
  calendar: ScheduledActivity[];
1703
+ }
1704
+ /** The agent's own day, whole, whenever a total moves: a task ended, a break ended. */
1705
+ | {
1706
+ type: "shift-updated";
1707
+ shift: Shift;
1671
1708
  };
1672
1709
  /**
1673
1710
  * The `Provider` prefix survives here for a mechanical reason rather than a naming one: `Event`
package/dist/testing.d.ts CHANGED
@@ -7,7 +7,7 @@ export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation }
7
7
  * rules -- each optional part of a task, each optional part of the break state and team member list, each
8
8
  * declared contribution, and each event type.
9
9
  */
10
- declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.history", "task.onCall", "task.leadAssist", "task.takenOver", "task.audio", "task.acceptance", "task.outcomes", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.forced", "team.members", "team.request", "team.tasks", "team.listening", "team.shift", "contacts", "calendar", "team.policies"];
10
+ declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.history", "task.onCall", "task.leadAssist", "task.takenOver", "task.audio", "task.acceptance", "task.outcomes", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.forced", "team.members", "team.request", "team.tasks", "team.listening", "team.shift", "shift", "contacts", "calendar", "team.policies"];
11
11
  export type ContractSubject = (typeof STATE_SUBJECTS)[number] | `event.${ProviderEvent["type"]}`;
12
12
  export interface AdapterContractResult {
13
13
  events: ProviderEventEnvelope[];
package/dist/testing.js CHANGED
@@ -28,6 +28,7 @@ const STATE_SUBJECTS = [
28
28
  "team.tasks",
29
29
  "team.listening",
30
30
  "team.shift",
31
+ "shift",
31
32
  "contacts",
32
33
  "calendar",
33
34
  "team.policies",
@@ -38,7 +39,7 @@ const EVENT_TYPES = {
38
39
  snapshot: true, "transport-status": true, "break-state": true, "task-offered": true, "task-updated": true,
39
40
  "task-audio-started": true, "task-audio-ended": true, "task-ended": true, "dial-outcome": true, announcement: true, "queue-summary": true, diagnostic: true,
40
41
  "team-updated": true, "team-member-updated": true, "team-member-removed": true, "team-policies-updated": true,
41
- "contacts-updated": true, "calendar-updated": true,
42
+ "contacts-updated": true, "calendar-updated": true, "shift-updated": true,
42
43
  };
43
44
  const CONTRACT_SUBJECTS = [
44
45
  ...STATE_SUBJECTS,
@@ -94,7 +95,7 @@ function observeMember(member, seen) {
94
95
  return;
95
96
  if (some(member.tasks))
96
97
  seen.add("team.tasks");
97
- if (member.listening !== undefined)
98
+ if (some(member.listening))
98
99
  seen.add("team.listening");
99
100
  if (member.shift !== undefined)
100
101
  seen.add("team.shift");
@@ -126,6 +127,8 @@ function observeSnapshot(value, seen) {
126
127
  seen.add("contacts");
127
128
  if (some(value.calendar))
128
129
  seen.add("calendar");
130
+ if (value.shift !== undefined)
131
+ seen.add("shift");
129
132
  }
130
133
  function observeEvent(envelope, seen) {
131
134
  const event = isRecord(envelope) ? envelope.event : undefined;
@@ -162,6 +165,9 @@ function observeEvent(envelope, seen) {
162
165
  if (some(event.calendar))
163
166
  seen.add("calendar");
164
167
  break;
168
+ case "shift-updated":
169
+ seen.add("shift");
170
+ break;
165
171
  default: break;
166
172
  }
167
173
  }
@@ -719,9 +725,14 @@ function userIdsIn(snapshot) {
719
725
  add(snapshot?.break?.forced?.by);
720
726
  const team = snapshot?.team;
721
727
  if (isRecord(team)) {
722
- for (const member of Array.isArray(team.members) ? team.members : [])
723
- if (isRecord(member))
724
- add(member.id);
728
+ for (const member of Array.isArray(team.members) ? team.members : []) {
729
+ if (!isRecord(member))
730
+ continue;
731
+ add(member.id);
732
+ for (const entry of Array.isArray(member.listening) ? member.listening : [])
733
+ if (isRecord(entry))
734
+ add(entry.leadId);
735
+ }
725
736
  }
726
737
  for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
727
738
  const t = task;
@@ -1063,7 +1074,7 @@ const REACHABLE_PHASES = {
1063
1074
  export const SUPERSEDED_BY_A_SNAPSHOT = new Set([
1064
1075
  "snapshot", "transport-status", "break-state", "task-offered", "task-updated", "task-ended",
1065
1076
  "task-audio-started", "task-audio-ended", "team-updated", "team-member-updated", "team-member-removed", "team-policies-updated",
1066
- "contacts-updated", "calendar-updated",
1077
+ "contacts-updated", "calendar-updated", "shift-updated",
1067
1078
  ]);
1068
1079
  export class TaskStream {
1069
1080
  tasks = new Map();
@@ -36,7 +36,7 @@ export function assertNoViolations(violations, summary) {
36
36
  */
37
37
  const membersOf = (members) => Object.keys(members);
38
38
  const CHANNELS = membersOf({ voice: true, chat: true, email: true });
39
- const TRANSPORT_RECOVERIES = membersOf({ reconnect: true, reauthenticate: true });
39
+ const TRANSPORT_RECOVERIES = membersOf({ reconnect: true, reauthenticate: true, displaced: true });
40
40
  const TASK_PHASES = membersOf({
41
41
  pending: true, confirmed: true, preview: true, "in-progress": true, paused: true, completing: true,
42
42
  });
@@ -1185,6 +1185,18 @@ function validateTaskInto(task, context, path, into) {
1185
1185
  else {
1186
1186
  into.require(isDurationSeconds(task.wrapAllowance), "task.wrapAllowance", `${path}.wrapAllowance`, "wrapAllowance must be a whole number of seconds, zero or more, or omitted under agent-command");
1187
1187
  }
1188
+ // How much wrap is left travels with the completing task, restated, so every screen counts the same number.
1189
+ if (task.phase === "completing" && task.wrapAllowance !== undefined) {
1190
+ if (task.wrapEndsInSeconds === undefined) {
1191
+ into.add("task.completing.wrapEndsInSeconds.required", `${path}.wrapEndsInSeconds`, "a completing task with a wrap allowance says how much of it is left, so a reloaded desk and a lead's screen count down the same number");
1192
+ }
1193
+ else {
1194
+ into.require(isDurationSeconds(task.wrapEndsInSeconds), "task.completing.wrapEndsInSeconds", `${path}.wrapEndsInSeconds`, "wrapEndsInSeconds is how much wrap is left: a whole number of seconds, zero or more, counted from this publication");
1195
+ }
1196
+ }
1197
+ else if (task.wrapEndsInSeconds !== undefined) {
1198
+ into.add("task.completing.wrapEndsInSeconds.unexpected", `${path}.wrapEndsInSeconds`, task.phase === "completing" ? "no wrap allowance was stated, so there is nothing to count down" : "wrapEndsInSeconds belongs to a completing task; before the wrap there is nothing to count");
1199
+ }
1188
1200
  // The channel is fixed per provider by its manifest, so a task claiming another one is a
1189
1201
  // task Omni would render with the wrong controls.
1190
1202
  into.require(task.channel === context.channel, "task.channel", `${path}.channel`, `a ${context.channel} provider may not publish a ${describeValue(task.channel)} task`);
@@ -1207,7 +1219,7 @@ function validateTaskInto(task, context, path, into) {
1207
1219
  into.violations.push(...validateRecordingState(task.recording.provider, `${path}.recording.provider`));
1208
1220
  }
1209
1221
  }
1210
- if (context.member !== true || task.browsers !== undefined)
1222
+ if (context.member !== true)
1211
1223
  validateBrowsers(task.browsers, `${path}.browsers`, into);
1212
1224
  validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
1213
1225
  validateHistory(task.history, `${path}.history`, into, { phase: task.phase, audio: task.audio });
@@ -1226,11 +1238,16 @@ function validateTaskInto(task, context, path, into) {
1226
1238
  validateTakenOver(task.takenOver, context.channel, `${path}.takenOver`, into);
1227
1239
  // Where the terms came from is stated with them: a host cannot tell "the platform permits
1228
1240
  // nothing" from "the terms could not be read" from the set alone, and the provider knows which.
1229
- if (context.member !== true || task.capabilitySource !== undefined)
1230
- into.oneOf(task.capabilitySource, CAPABILITY_SOURCES, "task.capabilitySource", `${path}.capabilitySource`);
1231
- const capabilities = task.capabilities;
1232
- if (context.member === true && capabilities === undefined && task.browsers === undefined)
1241
+ if (context.member === true) {
1242
+ // The lead's copy is the record, the room and the terms; the workspace stays on the member's desk.
1243
+ for (const field of ["capabilities", "capabilitySource", "browsers"]) {
1244
+ if (task[field] !== undefined)
1245
+ into.add("team.member.task.workspace", `${path}.${field}`, `${field} is the member's workspace, and never travels to a lead`);
1246
+ }
1233
1247
  return;
1248
+ }
1249
+ into.oneOf(task.capabilitySource, CAPABILITY_SOURCES, "task.capabilitySource", `${path}.capabilitySource`);
1250
+ const capabilities = task.capabilities;
1234
1251
  if (!isPlainObject(capabilities)) {
1235
1252
  into.add("task.capabilities.shape", `${path}.capabilities`, "a task needs a capabilities object");
1236
1253
  return;
@@ -1313,8 +1330,9 @@ function validateForcedBreak(value, path, into) {
1313
1330
  for (const field of ["endsAutomatically", "endsAt"]) {
1314
1331
  into.require(!Object.hasOwn(value, field), "break.forced.manualResume", `${path}.${field}`, "automatic ending fields are unsupported; the agent must resume manually");
1315
1332
  }
1316
- if (value.expectedDurationMs !== undefined)
1317
- into.require(typeof value.expectedDurationMs === "number" && Number.isFinite(value.expectedDurationMs) && value.expectedDurationMs > 0, "break.forced.expectedDurationMs", `${path}.expectedDurationMs`, "expectedDurationMs must be a positive finite number of milliseconds");
1333
+ into.require(!Object.hasOwn(value, "expectedDurationMs"), "break.forced.expectedDurationMs.renamed", `${path}.expectedDurationMs`, "the state says how long is left, as expectedEndsInSeconds; the duration asked for travels on the lead's command alone");
1334
+ if (value.expectedEndsInSeconds !== undefined)
1335
+ into.require(isDurationSeconds(value.expectedEndsInSeconds), "break.forced.expectedEndsInSeconds", `${path}.expectedEndsInSeconds`, "expectedEndsInSeconds is how long the break is expected to run yet: a whole number of seconds, zero or more, counted from this publication");
1318
1336
  }
1319
1337
  /** Checks break status against the full currently retained task set, after a transaction. */
1320
1338
  export function validateBreakStatus(state, tasks, path = "snapshot") {
@@ -1435,7 +1453,8 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
1435
1453
  into.require(asked !== undefined && (command.assignmentId === undefined || asked.assignmentId === command.assignmentId), "team.command.request", path, `${command.type} answers a request the team member list carries on the member, and this member has none`);
1436
1454
  }
1437
1455
  else if (command.type === "coach" || command.type === "join-call" || command.type === "leave") {
1438
- into.require(isPlainObject(target) && target.listening !== undefined, "team.command.listening", path, `${command.type} needs the lead on this member's call: the team member list shows listening on them`);
1456
+ // Every lead's entry is on the member; the one this command moves is the signed-in lead's.
1457
+ into.require(isPlainObject(target) && Array.isArray(target.listening) && target.listening.some((entry) => isPlainObject(entry) && entry.leadId === self), "team.command.listening", path, `${command.type} needs this lead on this member's call: the team member list shows listening on them naming this lead`);
1439
1458
  }
1440
1459
  return into.violations;
1441
1460
  }
@@ -1631,10 +1650,19 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
1631
1650
  }
1632
1651
  validateTeamMemberInto(member, at, context, into);
1633
1652
  });
1634
- // One member's call at a time.
1635
- const listened = teamMembers.members.filter((member) => isPlainObject(member) && member.listening !== undefined);
1636
- if (listened.length > 1)
1637
- into.add("team.listening.single", `${path}.members`, "a lead is on one member's call at a time");
1653
+ // One member's call at a time, per lead.
1654
+ const onCalls = new Map();
1655
+ for (const member of teamMembers.members) {
1656
+ if (!isPlainObject(member) || !Array.isArray(member.listening))
1657
+ continue;
1658
+ const leads = new Set(member.listening.filter(isPlainObject).map(entry => entry.leadId).filter((id) => typeof id === "string"));
1659
+ for (const leadId of leads)
1660
+ onCalls.set(leadId, (onCalls.get(leadId) ?? 0) + 1);
1661
+ }
1662
+ for (const [leadId, count] of onCalls) {
1663
+ if (count > 1)
1664
+ into.add("team.listening.single", `${path}.members`, `${leadId} is on ${count} members' calls: a lead is on one member's call at a time`);
1665
+ }
1638
1666
  }
1639
1667
  /** One member as the lead sees them, whole: on the list, and on `team-member-updated` alone. */
1640
1668
  export function validateTeamMember(member, path = "member", context = {}) {
@@ -1669,7 +1697,7 @@ function validateTeamMemberInto(member, at, context, into) {
1669
1697
  const held = new Set();
1670
1698
  member.tasks.forEach((task, index) => {
1671
1699
  const channel = isPlainObject(task) && typeof task.channel === "string" && isChannel(task.channel) ? task.channel : context.channel ?? "voice";
1672
- validateTaskInto(task, { channel, levels: context.levels, member: true }, `${at}.tasks[${index}]`, into);
1700
+ validateTaskInto(task, { channel, levels: context.levels, member: true, locked: context.locked }, `${at}.tasks[${index}]`, into);
1673
1701
  if (isPlainObject(task) && isAssignmentId(task.assignmentId)) {
1674
1702
  if (held.has(task.assignmentId))
1675
1703
  into.add("team.member.tasks.unique", `${at}.tasks[${index}].assignmentId`, `duplicate assignment on one member: ${task.assignmentId}`);
@@ -1679,23 +1707,41 @@ function validateTeamMemberInto(member, at, context, into) {
1679
1707
  into.require(member.availability !== "signed-out" || member.tasks.length === 0, "team.member.tasks.availability", `${at}.tasks`, "a member signed out holds no task");
1680
1708
  }
1681
1709
  }
1682
- // The lead on this member's call, in the mode they are heard.
1710
+ // Every lead on this member's call, in the mode they are heard, published to every lead alike.
1683
1711
  if (member.listening !== undefined) {
1684
- if (!isPlainObject(member.listening)) {
1685
- into.add("team.member.listening.shape", `${at}.listening`, "listening must be an object when present");
1712
+ if (!Array.isArray(member.listening)) {
1713
+ into.add("team.member.listening.shape", `${at}.listening`, "listening is an array of the leads on the member's call: absent while none is");
1686
1714
  }
1687
1715
  else {
1688
- const listened = member.listening;
1689
- if (into.require(isAssignmentId(member.listening.assignmentId), "team.member.listening.assignmentId", `${at}.listening.assignmentId`, "listening names which of the member's calls the lead is on: the application opens the lead's audio on it") && Array.isArray(member.tasks)) {
1690
- into.require(member.tasks.some((task) => isPlainObject(task) && task.assignmentId === listened.assignmentId), "team.member.listening.assignment", `${at}.listening.assignmentId`, "the call the lead is on is one the member's tasks carry");
1691
- }
1692
- into.oneOf(member.listening.mode, LISTENING_MODES, "team.member.listening.mode", `${at}.listening.mode`);
1693
- into.timestamp(member.listening.since, "team.member.listening.since", `${at}.listening.since`);
1716
+ into.require(member.listening.length > 0, "team.member.listening.empty", `${at}.listening`, "nobody on the call is absence, not an empty list");
1717
+ const leads = new Set();
1718
+ member.listening.forEach((entry, index) => {
1719
+ const here = `${at}.listening[${index}]`;
1720
+ if (!isPlainObject(entry)) {
1721
+ into.add("team.member.listening.entry", here, "each listening entry must be an object");
1722
+ return;
1723
+ }
1724
+ if (into.require(isUserId(entry.leadId), "team.member.listening.leadId", `${here}.leadId`, "a listening entry names the lead on the call, by user id")) {
1725
+ if (leads.has(entry.leadId))
1726
+ into.add("team.member.listening.unique", `${here}.leadId`, `${entry.leadId} is listed twice on one member: a lead is on a member's call once`);
1727
+ leads.add(entry.leadId);
1728
+ }
1729
+ const task = Array.isArray(member.tasks) ? member.tasks.find((task) => isPlainObject(task) && task.assignmentId === entry.assignmentId) : undefined;
1730
+ if (into.require(isAssignmentId(entry.assignmentId), "team.member.listening.assignmentId", `${here}.assignmentId`, "a listening entry names which of the member's calls the lead is on: the application opens its own audio on it") && Array.isArray(member.tasks)) {
1731
+ into.require(task !== undefined, "team.member.listening.assignment", `${here}.assignmentId`, "the call the lead is on is one the member's tasks carry");
1732
+ }
1733
+ if (into.oneOf(entry.mode, LISTENING_MODES, "team.member.listening.mode", `${here}.mode`) && entry.mode === "join-call"
1734
+ && isPlainObject(task) && Array.isArray(task.onCall) && isUserId(entry.leadId)) {
1735
+ // A lead who is heard is in the room the member sees; a silent one is not.
1736
+ into.require(task.onCall.some((who) => isPlainObject(who) && who.role === "agent" && who.userId === entry.leadId), "team.member.listening.heard", `${here}.mode`, `${entry.leadId} has joined the call and is heard, so the task's onCall carries them as an agent`);
1737
+ }
1738
+ into.timestamp(entry.since, "team.member.listening.since", `${here}.since`);
1739
+ });
1694
1740
  }
1695
1741
  }
1696
1742
  // The member's own history for the day: about the person, not any one call.
1697
1743
  if (member.shift !== undefined)
1698
- validateShiftInto(member.shift, `${at}.shift`, into);
1744
+ validateShiftInto(member.shift, `${at}.shift`, into, "team.member.shift");
1699
1745
  // The member's standing ask for a lead, on one of the calls they hold.
1700
1746
  if (member.request !== undefined) {
1701
1747
  if (!isPlainObject(member.request)) {
@@ -1721,6 +1767,18 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1721
1767
  const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
1722
1768
  const levels = context.levels ?? manifestLevels(manifest);
1723
1769
  into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.transport`);
1770
+ // The provider's own instant of the read: required where the provider has a clock to state, and
1771
+ // a clock nobody declared is not one the desk may count from.
1772
+ if (isPlainObject(manifest) && manifest.timeCheck === true) {
1773
+ into.timestamp(snapshot.providerTime, "snapshot.providerTime", `${path}.providerTime`);
1774
+ }
1775
+ else if (snapshot.providerTime !== undefined) {
1776
+ into.add("snapshot.providerTime.unexpected", `${path}.providerTime`, "providerTime is the clock of a provider that declares timeCheck; declare it, or send none");
1777
+ }
1778
+ requireClockInto(snapshot, manifest, path, into);
1779
+ // The agent's own day, as the provider counts it: the same numbers their lead sees.
1780
+ if (snapshot.shift !== undefined)
1781
+ validateShiftInto(snapshot.shift, `${path}.shift`, into, "shift");
1724
1782
  if (into.filled(snapshot.loginId, "snapshot.loginId", `${path}.loginId`, "a snapshot needs the login id it belongs to")
1725
1783
  && context.loginId !== undefined) {
1726
1784
  into.require(snapshot.loginId === context.loginId, "snapshot.loginId.mismatch", `${path}.loginId`, `a snapshot for session ${describeValue(snapshot.loginId)} on a login whose session is ${context.loginId}`);
@@ -1894,6 +1952,9 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1894
1952
  return into.violations;
1895
1953
  }
1896
1954
  const at = `${path}.event`;
1955
+ // A snapshot event is read by validateSnapshot, which asks the clock question of it there.
1956
+ if (event.type !== "snapshot")
1957
+ requireClockInto(event, manifest, at, into);
1897
1958
  into.require(!Object.hasOwn(event, "taskId"), "event.assignmentId.renamed", `${at}.taskId`, "an event names a task by its assignment alone; the former field is not accepted");
1898
1959
  switch (event.type) {
1899
1960
  case "snapshot":
@@ -2004,6 +2065,9 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
2004
2065
  event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into, levels));
2005
2066
  }
2006
2067
  break;
2068
+ case "shift-updated":
2069
+ validateShiftInto(event.shift, `${at}.shift`, into, "shift");
2070
+ break;
2007
2071
  case "calendar-updated":
2008
2072
  into.require(idle.calendar === true, "event.calendar.capability", `${at}.calendar`, "calendar-updated requires the calendar idle capability");
2009
2073
  if (!Array.isArray(event.calendar)) {
@@ -2055,38 +2119,65 @@ function validatePreferencesInto(value, path, into, levels) {
2055
2119
  });
2056
2120
  }
2057
2121
  /** The team's policy per capability as the lead sees it: the setting, who set it, who locked it. */
2058
- function validateShiftInto(value, path, into) {
2122
+ /** Whether a publication carries an instant the desk renders as a running duration, which the provider's clock reads. */
2123
+ function carriesRunningInstant(value) {
2124
+ if (!isPlainObject(value))
2125
+ return false;
2126
+ if (isPlainObject(value.shift) && value.shift.signedInAt !== undefined)
2127
+ return true;
2128
+ const tasks = [
2129
+ ...(Array.isArray(value.tasks) ? value.tasks : []),
2130
+ ...(isPlainObject(value.task) ? [value.task] : []),
2131
+ ];
2132
+ if (tasks.some(task => isPlainObject(task) && Array.isArray(task.onCall) && task.onCall.some((who) => isPlainObject(who) && who.since !== undefined)))
2133
+ return true;
2134
+ const members = [
2135
+ ...(isPlainObject(value.team) && Array.isArray(value.team.members) ? value.team.members : []),
2136
+ ...(isPlainObject(value.member) ? [value.member] : []),
2137
+ ];
2138
+ return members.some(member => isPlainObject(member) && (member.since !== undefined
2139
+ || (Array.isArray(member.listening) && member.listening.some((entry) => isPlainObject(entry) && entry.since !== undefined))
2140
+ || (isPlainObject(member.shift) && member.shift.signedInAt !== undefined)
2141
+ || (Array.isArray(member.tasks) && member.tasks.some((task) => isPlainObject(task) && Array.isArray(task.onCall) && task.onCall.some((who) => isPlainObject(who) && who.since !== undefined)))));
2142
+ }
2143
+ /** A running duration is counted from the provider's clock: a provider that publishes one declares timeCheck. */
2144
+ function requireClockInto(value, manifest, path, into) {
2145
+ if (!isPlainObject(manifest) || manifest.timeCheck === true || !carriesRunningInstant(value))
2146
+ return;
2147
+ into.add("manifest.timeCheck.required", path, "this publishes an instant the desk renders as a running duration -- a since, a signedInAt -- and the manifest declares no timeCheck: every screen counts such a duration from the provider's clock, so the provider states one");
2148
+ }
2149
+ function validateShiftInto(value, path, into, rule) {
2059
2150
  if (!isPlainObject(value)) {
2060
- into.add("team.member.shift.shape", path, "shift must be an object when present");
2151
+ into.add(`${rule}.shape`, path, "shift must be an object when present");
2061
2152
  return;
2062
2153
  }
2063
- const signedIn = into.timestamp(value.signedInAt, "team.member.shift.signedInAt", `${path}.signedInAt`);
2064
- if (value.signedOutAt !== undefined && into.timestamp(value.signedOutAt, "team.member.shift.signedOutAt", `${path}.signedOutAt`) && signedIn) {
2065
- into.require(Date.parse(value.signedOutAt) >= Date.parse(value.signedInAt), "team.member.shift.signedOutAt.order", `${path}.signedOutAt`, "a member signs out after signing in");
2154
+ const signedIn = into.timestamp(value.signedInAt, `${rule}.signedInAt`, `${path}.signedInAt`);
2155
+ if (value.signedOutAt !== undefined && into.timestamp(value.signedOutAt, `${rule}.signedOutAt`, `${path}.signedOutAt`) && signedIn) {
2156
+ into.require(Date.parse(value.signedOutAt) >= Date.parse(value.signedInAt), `${rule}.signedOutAt.order`, `${path}.signedOutAt`, "a member signs out after signing in");
2066
2157
  }
2067
2158
  for (const field of ["talkSeconds", "holdSeconds", "breakSeconds"]) {
2068
2159
  if (value[field] !== undefined)
2069
2160
  into.require(isDurationSeconds(value[field]), `team.member.shift.${field}`, `${path}.${field}`, `${field} must be a whole number of seconds, zero or more, or omitted when unknown`);
2070
2161
  }
2071
2162
  if (value.tasksHandled !== undefined)
2072
- into.require(Number.isInteger(value.tasksHandled) && value.tasksHandled >= 0, "team.member.shift.tasksHandled", `${path}.tasksHandled`, "tasksHandled must be a whole number, zero or more, or omitted when unknown");
2163
+ into.require(Number.isInteger(value.tasksHandled) && value.tasksHandled >= 0, `${rule}.tasksHandled`, `${path}.tasksHandled`, "tasksHandled must be a whole number, zero or more, or omitted when unknown");
2073
2164
  if (value.events !== undefined) {
2074
2165
  if (!Array.isArray(value.events)) {
2075
- into.add("team.member.shift.events.shape", `${path}.events`, "events must be an array when present");
2166
+ into.add(`${rule}.events.shape`, `${path}.events`, "events must be an array when present");
2076
2167
  return;
2077
2168
  }
2078
2169
  let previous;
2079
2170
  value.events.forEach((event, index) => {
2080
2171
  const at = `${path}.events[${index}]`;
2081
2172
  if (!isPlainObject(event)) {
2082
- into.add("team.member.shift.event.shape", at, "each shift event must be an object");
2173
+ into.add(`${rule}.event.shape`, at, "each shift event must be an object");
2083
2174
  return;
2084
2175
  }
2085
- into.oneOf(event.kind, SHIFT_EVENT_KINDS, "team.member.shift.event.kind", `${at}.kind`);
2086
- if (into.timestamp(event.at, "team.member.shift.event.at", `${at}.at`)) {
2176
+ into.oneOf(event.kind, SHIFT_EVENT_KINDS, `${rule}.event.kind`, `${at}.kind`);
2177
+ if (into.timestamp(event.at, `${rule}.event.at`, `${at}.at`)) {
2087
2178
  const instant = Date.parse(event.at);
2088
2179
  if (previous !== undefined && instant < previous)
2089
- into.add("team.member.shift.events.order", `${at}.at`, "shift events are oldest first; this event is earlier than the one before it");
2180
+ into.add(`${rule}.events.order`, `${at}.at`, "shift events are oldest first; this event is earlier than the one before it");
2090
2181
  previous = instant;
2091
2182
  }
2092
2183
  });
package/guide.md CHANGED
@@ -474,11 +474,13 @@ type PreferenceResult =
474
474
  type Snapshot = {
475
475
  transport: TransportStatus;
476
476
  loginId: string;
477
+ providerTime?: IsoTimestamp;
477
478
  break: BreakState;
478
479
  tasks: Task[];
479
480
  taskCount: number;
480
481
  contacts?: Contact[];
481
482
  calendar?: ScheduledActivity[];
483
+ shift?: Shift;
482
484
  team?: TeamMembers;
483
485
  };
484
486
 
@@ -726,6 +728,7 @@ type Task<C extends Channel = Channel> = {
726
728
  expiresInSeconds?: DurationSeconds;
727
729
  previewEndsInSeconds?: DurationSeconds;
728
730
  atDeadline?: PreviewDeadline;
731
+ wrapEndsInSeconds?: DurationSeconds;
729
732
  reference?: string;
730
733
  attributes?: TaskAttribute[];
731
734
  history?: TaskHistory;
@@ -880,7 +883,15 @@ The adapter is the one that knows the value, so it is the one held to it: given
880
883
  queue locked, the validator refuses any other field carrying one, digits compared as digits so no
881
884
  formatting hides them (`task.locked.leak`), and a conformance run whose tasks lock a party's number
882
885
  or email states those values in `lockedValues`, since a run that cannot ask the question is not a
883
- pass. An agent application never has the value and never asks. A name is not locked. What the queue provides rather than permits — browsers, outcomes, custom
886
+ pass. An agent application never has the value and never asks. A name is not locked.
887
+
888
+ **The lock is per audience.** Who sees the number is the provider's choice for each copy of the
889
+ task: the member's, on their desk, and the leads', on the team member list. Hidden from the agent
890
+ and shown to the lead, shown to the agent and hidden from the lead, hidden from both, shown to
891
+ both -- any of the four, as the platform's policy says, and each copy is the provider's statement
892
+ to that audience. Each copy keeps its own lock whole: where the lead's copy locks the number,
893
+ nothing else on that copy carries it, exactly as on the member's (`task.locked.leak`, applied to a
894
+ member's tasks with the same `lockedValues`). What the queue provides rather than permits — browsers, outcomes, custom
884
895
  controls — is content, and is never locked.
885
896
 
886
897
  **A lead sets the team's policy from their team member list.** A login that declares
@@ -933,7 +944,7 @@ type BreakRequest = {
933
944
 
934
945
  type ForcedBreak = {
935
946
  by: UserId | "provider";
936
- expectedDurationMs?: number;
947
+ expectedEndsInSeconds?: DurationSeconds;
937
948
  };
938
949
 
939
950
  type BreakState = {
@@ -975,8 +986,8 @@ type TeamMember = {
975
986
  since?: IsoTimestamp;
976
987
  break?: Extract<BreakStatus, "awaiting-approval" | "granted" | "starting-after-task">;
977
988
  tasks?: MemberTask[];
978
- listening?: MemberListening;
979
- shift?: MemberShift;
989
+ listening?: MemberListening[];
990
+ shift?: Shift;
980
991
  request?: MemberRequest;
981
992
  };
982
993
 
@@ -996,16 +1007,15 @@ type MemberTask<C extends Channel = Channel> = {
996
1007
  reference?: string;
997
1008
  attributes?: TaskAttribute[];
998
1009
  history?: TaskHistory;
999
- capabilities?: TaskCapabilities<C>;
1000
- capabilitySource?: CapabilitySource;
1001
- browsers?: TaskBrowser[];
1002
1010
  completionMode?: CompletionMode;
1003
1011
  wrapAllowance?: DurationSeconds;
1012
+ wrapEndsInSeconds?: DurationSeconds;
1004
1013
  } & (C extends "voice"
1005
1014
  ? { onCall?: OnCall[]; leadAssist?: TaskLeadAssist; takenOver?: TaskTakenOver; audio?: TaskAudioState }
1006
1015
  : { onCall?: never; leadAssist?: never; takenOver?: never; audio?: never });
1007
1016
 
1008
1017
  type MemberListening = {
1018
+ leadId: UserId;
1009
1019
  assignmentId: AssignmentId;
1010
1020
  mode: ListeningMode;
1011
1021
  since: IsoTimestamp;
@@ -1018,7 +1028,7 @@ type ShiftEvent = {
1018
1028
  kind: ShiftEventKind;
1019
1029
  };
1020
1030
 
1021
- type MemberShift = {
1031
+ type Shift = {
1022
1032
  signedInAt: IsoTimestamp;
1023
1033
  signedOutAt?: IsoTimestamp;
1024
1034
  talkSeconds?: DurationSeconds;
@@ -1110,7 +1120,7 @@ type QueueSummary = {
1110
1120
  metrics?: SummaryMetric[];
1111
1121
  };
1112
1122
 
1113
- type TransportRecovery = "reconnect" | "reauthenticate";
1123
+ type TransportRecovery = "reconnect" | "reauthenticate" | "displaced";
1114
1124
 
1115
1125
  type ProviderEvent =
1116
1126
  | { type: "snapshot"; reason: "reconnected" | "provider-requested"; snapshot: Snapshot }
@@ -1134,7 +1144,8 @@ type ProviderEvent =
1134
1144
  | { type: "team-member-removed"; memberId: UserId }
1135
1145
  | { type: "team-policies-updated"; policies: TeamPolicies }
1136
1146
  | { type: "contacts-updated"; contacts: Contact[] }
1137
- | { type: "calendar-updated"; calendar: ScheduledActivity[] };
1147
+ | { type: "calendar-updated"; calendar: ScheduledActivity[] }
1148
+ | { type: "shift-updated"; shift: Shift };
1138
1149
 
1139
1150
  type ProviderEventEnvelope = {
1140
1151
  id: string;
@@ -1626,7 +1637,7 @@ compile time.
1626
1637
  | `orgLevels` | The organisation's whole ladder as the provider calls it, each level with the label a desk shows for "who decided". Stated outright, `person` included: what it leaves out does not exist. Omitted for the typical four, `DEFAULT_LEVELS`. See **Who decides what an agent may do**. |
1627
1638
  | `phones` | Voice only, and required there: the phones this platform can put an agent on, `softphone` (the call's audio lands in the agent application) and/or `deskPhone` (a handset the platform rings; the agent application shows the call and opens nothing). The agent application picks one per login. See **How the agent hears the call**. |
1628
1639
  | `dialOutcomes` | Voice only. How a dial can end on this platform, as it distinguishes them: `answered` and at least one way of not reaching the destination. Required of a provider that dials at all — an idle dialpad, or tasks that conference or call back — and a `dial-outcome` carries only a declared member. See **Every dial has an outcome**. |
1629
- | `timeCheck` | Optional `true`: implements `checkTime` for fresh provider-clock samples; agent application polling is independently opt-in. |
1640
+ | `timeCheck` | `true`: implements `checkTime` and states `providerTime` on every snapshot. Required of a provider that publishes an instant the desk renders as a running duration -- a member's `since`, a lead's `listening.since`, an `onCall.since`, a shift's `signedInAt` -- so every screen counts from the provider's clock (`manifest.timeCheck.required`); agent application polling is independently opt-in. See **Every screen counts from the provider's clock**. |
1630
1641
  | `timestampAuthority` | Optional `"provider"`: provider timestamps are final; agent application instants are advisory. Omission makes no trust promise. |
1631
1642
  | `runningStepReports` | The provider takes running reports of an agent application-performed step — `recordStep` with `seconds` so far and no `ended`. Omitted, the agent application sends exactly two reports per leg, when it began and when it ended, and a running one is refused. See **The agent application records what it performs**. |
1632
1643
  | `settleMs` | Required. How long after `applied` the wire shows what the provider did: the `task-ended` after a `complete`, the `calendar-updated` after a `schedule`. A positive whole number of milliseconds (`manifest.settleMs`). Stated per provider, since platforms settle at different speeds. See **`task-ended`**. |
@@ -2069,6 +2080,12 @@ Creates one live provider connection for the signed-in agent.
2069
2080
  - Must resolve only when the connection can provide a meaningful snapshot.
2070
2081
  - May reject for authentication, configuration, or startup failure.
2071
2082
  - Must not create a second agent session merely because the underlying transport reconnects.
2083
+ - **One client at a time per login, and the later one wins.** A second `connect()` on a login
2084
+ that already has a live client is the agent moving desks -- a desk left open at work, signed in
2085
+ from home. The provider takes the new connection, and ends the first with `transport-status`
2086
+ `error` and `recovery: "displaced"`, so the first desk stops stating capacity and reporting legs
2087
+ and shows the agent they are signed in elsewhere. Two live clients on one login would be two
2088
+ sources for one agent, and the lead's list would follow whichever spoke last.
2072
2089
  - The returned connection owns reconnect until Omni calls `disconnect()` or aborts `context.signal`.
2073
2090
  - May be called again on the same login after that: once per `Connection`, not once per login.
2074
2091
  Omni disposes a connection whose `error` named `recovery: "reconnect"` with `disconnect()` and
@@ -2142,11 +2159,13 @@ a capability it agrees with the login: a lead's snapshot carries `team`, nobody
2142
2159
  | --- | --- |
2143
2160
  | `transport` | Current `TransportStatus` — whether this provider's transport can serve the login. Defined under **`transport-status`**. |
2144
2161
  | `loginId` | Identity of this login. It must match the connection context. |
2162
+ | `providerTime` | The provider's own instant of this read. Required of a provider that declares `timeCheck` (`snapshot.providerTime`), forbidden of one that does not (`snapshot.providerTime.unexpected`): a clock sample the desk counts durations from, saving it a `checkTime`. |
2145
2163
  | `break` | Complete break state, including status, whether the agent may ask, reasons, retry details, and any forced break. |
2146
2164
  | `tasks` | Complete set of tasks currently offered to or owned by this agent. |
2147
2165
  | `taskCount` | The provider's own count of those tasks, stated rather than inferred, and it must equal `tasks.length`. A snapshot with no work says `taskCount: 0` in so many words — a blank or unanswered state lacks the count and cannot pass as a confirmed empty. |
2148
2166
  | `contacts` | Required complete contact contribution when the manifest declares `contacts`; `[]` clears it. Omitted only when it does not. |
2149
2167
  | `calendar` | Required complete calendar contribution when the manifest declares `calendar`; `[]` clears it. Omitted only when it does not. |
2168
+ | `shift` | The agent's own day so far, as the provider counts it -- the same `Shift` their lead sees on the team member list, the same numbers from the same count. Replaced whole by `shift-updated`. Omitted only where the provider cannot say. See **The agent's day is on the wire**. |
2150
2169
  | `team` | Required `TeamMembers` when the login declares `capabilities.lead` and the lead has the team feature on, `members: []` when nobody is in it. Forbidden otherwise — the login is the permission, and a lead who turned the feature off gets nothing of the team. |
2151
2170
 
2152
2171
  ## Live connection
@@ -2407,7 +2426,8 @@ time. Runtime conformance checks also require the task channel to match its prov
2407
2426
  | `atDeadline` | Voice only, in `preview`, with `previewEndsInSeconds`: what the system does when it runs out -- `provider-dials` makes the provider initiate dialing, `host-dials` makes the agent application issue Call, `waits` keeps the task in preview awaiting the agent. |
2408
2427
  | `reference` | Optional agent-facing reference such as a case, call, conversation, ticket, or message number. It is distinct from the protocol `id`. |
2409
2428
  | `completionMode` | `agent-command` waits for the channel's `complete` command; `provider-automatic` completes without one, and takes the agent's `complete` as finishing early. A required outcome is the agent's to give, so it needs `agent-command` (`task.outcomes.required.mode`). |
2410
- | `wrapAllowance` | Fixed time allowed to complete the task after primary interaction ends. For real-time audio, it begins after `task-audio-ended`. Required under `provider-automatic`, where the provider acts on it. Optional under `agent-command`: omitted says the provider imposes no deadline, and Omni counts nothing down. `0` is no wrap at all, so a task with it publishes no `outcomes` (`task.outcomes.wrapAllowance`). |
2429
+ | `wrapAllowance` | Fixed time allowed to complete the task after primary interaction ends. For real-time audio, it begins after `task-audio-ended`. Required under `provider-automatic`, where the provider acts on it. Optional under `agent-command`: omitted says the provider imposes no deadline, and Omni counts nothing down; stated, it is the expected wrap, shown and never acted on. `0` under `provider-automatic` is no wrap at all, so such a task publishes no `outcomes` (`task.outcomes.wrapAllowance`). |
2430
+ | `wrapEndsInSeconds` | In `completing`, wherever `wrapAllowance` is stated: how much of the wrap is left, in whole seconds from this publication, restated on every publication that carries it (`task.completing.wrapEndsInSeconds`, `.required`, `.unexpected`). A reloaded desk and a lead's screen count down the same number. See **Completion timing**. |
2411
2431
  | `attributes` | Optional ordered, typed `TaskAttribute` entries with keys unique within the task. Each contact or timestamp is a separate array item; new attribute shapes require new union members. |
2412
2432
  | `history` | The call record: `steps` — the ordered interaction history of this open task, one entry per occurrence, oldest first — and what they add up to before this agent, `interactionSeconds`, `holdSeconds`, `queueSeconds`, `transfers`, each present when the provider knows it. Live task data restated with the task, not a permanent archive. See **Interaction history**. |
2413
2433
  | `onCall` | Voice only. Who is on the call, or being brought onto it, as the provider states it, replaced whole with the task: `party` is the customer -- carrying a `stage` while being dialled again on the same task, a connect-back with the agent application's `dialId` or a platform's callback without, ringing from the moment the dial is placed and joined on its answered outcome --, `agent` a person by user id, `conferenced` somebody a dial is bringing in, listed from the moment the dial is placed -- with the `destinationId` dialled, the `dialId` where an agent application placed it, the `stage` reached (`ringing` until answered, `joined` after), and `held: true` on anyone joined and parked. `label` names a destination -- a person, a queue -- not a phrase; the agent application supplies the verb. Present when the provider knows the room, absent when it does not. See **Every dial has an outcome**. |
@@ -2643,6 +2663,15 @@ nothing acts on it -- the task waits for `complete` however long that takes, wit
2643
2663
  collected whenever the agent gives them. A provider that would end the task at a time is
2644
2664
  `provider-automatic`.
2645
2665
 
2666
+ **What is left travels with the task.** The allowance is the length; `wrapEndsInSeconds` is how
2667
+ much of it remains, stated on the `completing` task and restated on every publication that
2668
+ carries it, as `previewEndsInSeconds` is on a preview. A desk that reloads mid-wrap finds the
2669
+ number on the snapshot; a lead's screen finds it on the member; neither starts a clock of its own,
2670
+ and both count down from receipt. Under `provider-automatic` the provider ends the task when it
2671
+ reaches zero; under `agent-command` it is the expected wrap left, `0` once overrun, and nothing
2672
+ acts on it. Off voice, where `completing` is the provider's word that interaction ended, the field
2673
+ arrives with that word, so the clock starts nowhere else.
2674
+
2646
2675
  `wrapAllowance` is fixed, and when it starts depends on whether the channel carries real-time
2647
2676
  audio:
2648
2677
 
@@ -2798,6 +2827,15 @@ Migration from the earlier spellings:
2798
2827
  | schedule on voice alone | `schedule` on every channel, under a manifest that declares `calendar` (`task.capability.calendar.required`), bounded by `settleMs` (`drive.schedule.unsettled`) |
2799
2828
  | a forced break requesting breaks on the other providers | `setCapacity({ count: 0 })` on every other usable provider (`assertForcedBreakStopsTheRest`) |
2800
2829
  | end-forced-break on a break the platform imposed | refused: `team.command.endForcedBreak.provider`, `omni.break-forced-by-provider` |
2830
+ | ForcedBreak.expectedDurationMs | `expectedEndsInSeconds`: seconds left, restated (`break.forced.expectedEndsInSeconds`, `.expectedDurationMs.renamed`); the command keeps `expectedDurationMs` |
2831
+ | MemberShift; the day on the lead's list alone | `Shift`, on `Snapshot.shift` and `shift-updated` for the agent, on `TeamMember.shift` for the lead |
2832
+ | TeamMember.listening as this lead's one entry | `MemberListening[]`, every lead's, each with `leadId` (`team.member.listening.leadId`, `.unique`, `.empty`, `.entry`, `.heard`) |
2833
+ | a member's history trimmed on the lead's list | the record whole, as the member's desk holds it |
2834
+ | durations counted from the screen's clock | the provider's: `timeCheck` required of a provider publishing a running instant (`manifest.timeCheck.required`), `Snapshot.providerTime` with it (`snapshot.providerTime`, `.unexpected`) |
2835
+ | a wrap the desk timed from an event's receipt | `Task.wrapEndsInSeconds` in `completing`, restated (`task.completing.wrapEndsInSeconds`, `.required`, `.unexpected`) |
2836
+ | MemberTask.capabilities, capabilitySource, browsers | gone: the workspace never travels to a lead (`team.member.task.workspace`); the record and the completion terms do |
2837
+ | a member republished on a hold, a mute, a take-over | on every publication of the member's task to the member |
2838
+ | two clients on one login | the later wins; the first is ended with `recovery: "displaced"` |
2801
2839
 
2802
2840
  Update producers, consumers, saved task snapshots, and validation-rule assertions together.
2803
2841
  History and report rule names use `history` and `historyReport`; assignment rules use
@@ -2919,7 +2957,8 @@ Four rules a provider has to keep:
2919
2957
  - **Report `seconds`; never expect Omni to derive it.** Omni does not subtract one timestamp from
2920
2958
  the next. An entry can be written while its leg is still running, so the arithmetic has no second
2921
2959
  operand, and a provider holding the authoritative number should not have it recomputed from
2922
- instants that may be rounded or clock-skewed.
2960
+ instants that may be rounded or clock-skewed. And once stated, `seconds` is what every screen
2961
+ shows for that leg: a count a screen kept while the leg ran is replaced by it, never kept beside it.
2923
2962
  - **Omit `seconds` while it is unknown. Never send `0`.** A leg still talking is not a zero-second
2924
2963
  conversation, and on live data that is the ordinary case rather than an edge. A zero is rejected.
2925
2964
  - **`by` is a bare `UserId`, and not necessarily an agent.** A lead or a manager takes part
@@ -3747,14 +3786,14 @@ The team break command formerly named release is now `end-forced-break`, and its
3747
3786
  `team.break.command.endForcedBreak`. The former release command and the interim end spelling are rejected without aliases.
3748
3787
  The agent’s `endBreak` now applies to forced breaks as well as requested breaks.
3749
3788
 
3750
- `ForcedBreak` names who forced the break and may include an advisory `expectedDurationMs`.
3751
- The agent must explicitly resume when ready. The retired endsAutomatically and endsAt fields
3752
- are rejected, including when an expected duration is also provided.
3753
-
3754
- **Every forced break has a person behind it.** A lead or a manager forced it; there is no such
3755
- thing as a break the platform forced on its own. Where a platform applies one automatically, it is
3756
- executing a preference somebody configured, and that person is the owner of the action — `by` names
3757
- them, not the machinery that carried it out.
3789
+ `ForcedBreak` names who forced the break and may say how long it is expected to run yet, as
3790
+ `expectedEndsInSeconds`: whole seconds from the publication that carries it, restated on every
3791
+ publication, so every screen -- the agent's, reloaded or not, and every lead's -- counts down the
3792
+ same number from receipt (`break.forced.expectedEndsInSeconds`). The duration the lead asked for
3793
+ travels on the `force-break` command alone; the state says what is left, and drops the field once
3794
+ it has run out. The agent must explicitly resume when ready. The retired endsAutomatically and
3795
+ endsAt fields are rejected, including when an expectation is also provided. Who forced it is
3796
+ `by`, as **A forced break is the provider's act** below sets out.
3758
3797
 
3759
3798
  Omni resolves the name to show with `getUserDetails()`, so a provider sends the identifier and never
3760
3799
  a display name.
@@ -3769,16 +3808,16 @@ For example:
3769
3808
  ```ts
3770
3809
  forced: {
3771
3810
  by: "manager-1042",
3772
- expectedDurationMs: 600000
3811
+ expectedEndsInSeconds: 600
3773
3812
  }
3774
3813
  ```
3775
3814
 
3776
3815
  The agent application keeps **Resume** available for an agent on a forced break. The agent explicitly chooses
3777
3816
  Resume; the agent application sends `endBreak()` and follows the provider-confirmed state before resuming
3778
- work. An expected duration is a positive finite number of milliseconds, measured from actual
3779
- entry into `on-break`, excluding any `starting-after-task` wait. Omission means no expected
3780
- duration was supplied. Neither duration expiry nor an overdue indication authorizes the agent application
3781
- or provider to end the break, restore availability, or route work to the agent.
3817
+ work. The expectation is whole seconds still to run, restated on every publication; omission
3818
+ means none was stated, or it has run out. Neither its reaching zero nor an overdue indication
3819
+ authorizes the agent application or provider to end the break, restore availability, or route
3820
+ work to the agent.
3782
3821
 
3783
3822
  **A forced break is the provider's act.** A lead's `force-break` is a command to the provider,
3784
3823
  which forces the break on the member; `by` names the lead who asked it to. A break the platform
@@ -3802,9 +3841,9 @@ must correlate resumption to the authenticated agent's action; a state-only orde
3802
3841
  cannot establish the cause of a transition. The usual multi-provider end/reconciliation flow
3803
3842
  still applies, and work resumes only on confirmed provider state.
3804
3843
 
3805
- An agent application may display the expected duration. A countdown requires an evidenced actual start;
3806
- a received snapshot, replay or reconnect is not a new start and cannot restart the duration.
3807
- Without that evidence, show the duration without inventing a start or return time.
3844
+ An agent application counts `expectedEndsInSeconds` down from receipt, on every publication that
3845
+ carries it, and shows nothing of it where the provider sends none. It invents no start and no
3846
+ return time, and nothing acts on the number reaching zero.
3808
3847
 
3809
3848
  A break applies to the **agent**, not to one provider. When a provider forces one, Omni stops the
3810
3849
  agent everywhere else at once, and not by asking: it states `setCapacity({ count: 0 })` on every
@@ -4210,10 +4249,10 @@ computes nothing. See **`team-updated`** and its companions.
4210
4249
  | `availability` | Required. What the member is doing now. |
4211
4250
  | `since` | Optional. When the current `availability` began — not when they signed in, and not when the team member list was read. |
4212
4251
  | `break` | Present only while the member has an outstanding break request. See **A member waiting for a break**. |
4213
- | `tasks` | The member's open tasks as `MemberTask`s: the same task the member's desk holds, trimmed by the provider. The assignment, title, task type, phase, party, room and audio are what the lead reads; the task's history is carried in the same shape as on the member's desk, as much of it as the provider chooses to send; the workspace — controls, their source, browsers, completion terms — is the member's and travels only if the provider sends it. `[]` when the member holds none; omitted only where the provider cannot see them. |
4214
- | `listening` | Present while this lead's channel is in this member's call — listening, coaching or joined — naming which of the member's calls by `assignmentId`, the `mode` they are heard in, and since when. Its appearance is what the lead's audio opens on. One member's call at a time (`team.listening.single`). |
4252
+ | `tasks` | The member's open tasks as `MemberTask`s: the same task the member's desk holds, less the workspace. The assignment, title, task type, phase, party, room, audio and completion terms -- `completionMode`, `wrapAllowance`, `wrapEndsInSeconds` -- are what the lead reads, and the task's record -- `history`, steps and totals -- travels whole, exactly as the member's desk holds it, so the lead's screen and the member's show the same record. The workspace -- `capabilities`, `capabilitySource`, `browsers` and their URLs -- is the member's desk's alone and never travels (`team.member.task.workspace`). The party's number and email are shown to leads or locked as the provider decides for leads, apart from what it decides for the member -- see **What the queue locks is locked on the whole task**. `[]` when the member holds none; omitted only where the provider cannot see them. |
4253
+ | `listening` | Every lead on this member's call — listening, coaching or joined — published to every lead alike: each entry names the lead by `leadId`, which of the member's calls by `assignmentId`, the `mode` they are heard in, and since when. The entry naming the signed-in lead is what their own audio opens on. Absent while nobody is on the call, never `[]` (`team.member.listening.*`); a lead is on one member's call at a time (`team.listening.single`), and a lead who is heard is also in the task's `onCall` (`team.member.listening.heard`). |
4215
4254
  | `request` | Present while the member is asking a lead to join one of their calls: which assignment, the note they wrote, since when. The call named is one their `tasks` carry (`team.member.request.assignment`). Absent when they are not asking. See **Lead assist**. |
4216
- | `shift` | The member's own history for the day, about the person rather than any one call: `signedInAt`, `signedOutAt` once it has happened, today's totals as the provider counts them — `talkSeconds`, `holdSeconds`, `breakSeconds`, `tasksHandled`, each present only when the provider knows it — and the day's sign-in, sign-out and break `events`, oldest first. Omitted where the provider cannot say. |
4255
+ | `shift` | The member's own history for the day, about the person rather than any one call: `signedInAt`, `signedOutAt` once it has happened, today's totals as the provider counts them — `talkSeconds`, `holdSeconds`, `breakSeconds`, `tasksHandled`, each present only when the provider knows it — and the day's sign-in, sign-out and break `events`, oldest first. The same `Shift` the member's own snapshot carries, the same numbers from the same count. Omitted where the provider cannot say. |
4217
4256
 
4218
4257
  Each availability value means one thing:
4219
4258
 
@@ -4236,11 +4275,28 @@ by it, as a colleague signing in is. `team-member-removed` takes one off. Never
4236
4275
  member, never a task of theirs on its own: a hold on a member's desk is that member republished
4237
4276
  whole on the lead's, and nothing else on the lead's screen moves.
4238
4277
 
4278
+ **Whatever the member's desk hears of a task, every lead hears of the member.** The trigger is
4279
+ not a list of changes but the publication itself: each `task-offered`, `task-updated`,
4280
+ `task-audio-started`, `task-audio-ended` and `task-ended` the provider sends to the member is
4281
+ matched by a `team-member-updated` to every lead with the feature on, carrying the member whole
4282
+ with the task as it now stands, or without it once it has ended. A held leg closing with its
4283
+ `seconds`, a room restated, a phase moving, the wrap left ticking on a restatement: each reaches
4284
+ the lead's copy in the same round it reaches the member's, and the two copies of the record never
4285
+ part for longer than the delay between them.
4286
+
4287
+ **The agent's day is on the wire.** The lead sees `shift` totals as the provider counts them, and
4288
+ the agent sees the same: `Snapshot.shift` and `shift-updated` carry the agent's own day in the same
4289
+ shape, from the same count, so "calls today: 12" is one number on both screens. The desk computes
4290
+ nothing of the day; `tasksHandled` moves when a task ends and `talkSeconds` when a leg closes, and
4291
+ the active call's running time is the screen's own until then, as **Every screen counts from the
4292
+ provider's clock** sets out.
4293
+
4239
4294
  **Omit `since` rather than inventing one.** Omni renders it as a duration, so a timestamp
4240
4295
  synthesised from the adapter's own clock at seed time reads as "on task for 0 seconds" for
4241
4296
  everybody — worse than showing nothing, because it looks like data. Send it only when the provider
4242
4297
  knows when the state actually began. It times the current `availability`, so it moves every time
4243
- that value does.
4298
+ that value does. And it is counted from the provider's clock, never the screen's, so a provider
4299
+ that publishes it declares `timeCheck` -- see **Every screen counts from the provider's clock**.
4244
4300
 
4245
4301
  **The login is the permission.** A team member list goes to a login that declares
4246
4302
  `capabilities.lead`, once the team feature is on, and to nobody else. Omni never decides who
@@ -4333,9 +4389,10 @@ executeTeam({ command: { type: "decline", memberId: "A-1", reason: "In a call" }
4333
4389
  ```
4334
4390
 
4335
4391
  **On `join` the provider bridges the lead's channel into the call.** The member's task moves to
4336
- `leadAssist: { stage: "joined", leadId }`, the member on the team member list carries
4337
- `listening: { assignmentId, mode: "join-call", since }`, and the lead's audio opens on it as
4338
- **Listening to a call** describes. Nothing is a task on the lead's desk: a join is the lead's act on the team surface, not
4392
+ `leadAssist: { stage: "joined", leadId }` and its `onCall` gains the lead as an `agent`, since a
4393
+ lead who is heard is in the room; the member on the team member list carries
4394
+ `listening: [{ leadId, assignmentId, mode: "join-call", since }]`, and the lead's audio opens on
4395
+ it as **Listening to a call** describes. Nothing is a task on the lead's desk: a join is the lead's act on the team surface, not
4339
4396
  work assigned to them.
4340
4397
 
4341
4398
  **A lead is on one member's call at a time.** A `join` from a lead already on a call -- their own
@@ -4403,10 +4460,11 @@ history. The three modes determine who hears the lead:
4403
4460
  All three come with the `lead` flag; a centre reserves none of them per lead.
4404
4461
 
4405
4462
  **There is no team audio.** Audio is the agent's or the lead's, and the lead's audio while
4406
- listening follows the provider's published state exactly as a task's does: `listening` on the
4407
- member is the provider's word that the lead's channel is in the call. When it appears, the
4408
- application opens `openAudio({ assignmentId })` on the lead's connection for the call it names,
4409
- and plays the `CallAudio` returned; when it goes, the application closes it. Nothing else says
4463
+ listening follows the provider's published state exactly as a task's does: the entry naming this
4464
+ lead in `listening` on the member is the provider's word that the lead's channel is in the call.
4465
+ When it appears, the application opens `openAudio({ assignmentId })` on the lead's connection for
4466
+ the call it names, and plays the `CallAudio` returned; when it goes, the application closes it.
4467
+ Another lead's entry opens nothing: it is shown, and that is all. Nothing else says
4410
4468
  when the lead's audio is up, and a reload reopens it from the snapshot's team member list as a task
4411
4469
  carried with `audio: "started"` is reopened.
4412
4470
 
@@ -4417,13 +4475,15 @@ The flow, end to end:
4417
4475
  // 2. The lead's channel joins it in silence. Conditions: the lead's voice channel is free --
4418
4476
  // not on a call, not listening elsewhere. Capacity is not involved: nothing is assigned.
4419
4477
  executeTeam({ command: { type: "listen", memberId: "A-1", assignmentId: "alloc-42" } })
4420
- // 3. The provider publishes the member whole on team-member-updated; the member carries
4421
- // listening: { assignmentId: "alloc-42", mode: "listen", since }
4422
- // -- the application opens openAudio({ assignmentId: "alloc-42" }) on the lead's connection.
4423
- // 4. The lead changes how they are heard; the member is published again with the new listening.mode. The audio stays open.
4478
+ // 3. The provider publishes the member whole on team-member-updated, to every lead; the member carries
4479
+ // listening: [{ leadId: "L-9", assignmentId: "alloc-42", mode: "listen", since }]
4480
+ // -- L-9's application opens openAudio({ assignmentId: "alloc-42" }) on its own connection; every
4481
+ // other lead's shows L-9 on the call and opens nothing.
4482
+ // 4. The lead changes how they are heard; the member is published again with the entry's new mode. The audio stays open.
4483
+ // join-call is heard by everyone, so the member's task is also republished with L-9 on onCall as an agent.
4424
4484
  executeTeam({ command: { type: "coach", memberId: "A-1" } })
4425
4485
  executeTeam({ command: { type: "join-call", memberId: "A-1" } })
4426
- // 5. The lead leaves: the provider publishes the member without listening; the application closes the audio.
4486
+ // 5. The lead leaves: the provider publishes the member without L-9's entry, and the task without L-9 in the room; the application closes the audio.
4427
4487
  executeTeam({ command: { type: "leave", memberId: "A-1" } })
4428
4488
  // 6. The member's call ends: the same as leave, from the provider's side.
4429
4489
  // 7. The lead takes the call: the member loses listening no later than the offer, the application
@@ -4434,22 +4494,29 @@ executeTeam({ command: { type: "take-over-call", memberId: "A-1" } })
4434
4494
  // 8. A join on the member's request is the same flow entered at step 3 with mode: "join-call",
4435
4495
  // and the member's task shows leadAssist: joined.
4436
4496
  // 9. A reload of the lead's application: the switch is sent again, the team-updated that follows
4437
- // still carries listening on the member, so the audio is reopened.
4497
+ // still carries this lead's entry in listening on the member, so the audio is reopened.
4438
4498
  ```
4439
4499
 
4440
- **`listening` on the member is the state**, restated on every change of mode, naming the call so
4441
- the application knows which audio to open (`team.member.listening.assignmentId`, and one the
4442
- member's tasks carry, `team.member.listening.assignment`), and a lead is on one member's call at a
4443
- time (`team.listening.single`).
4500
+ **`listening` on the member is the state**, one entry per lead on the call, restated on every
4501
+ change of mode, naming the lead (`team.member.listening.leadId`, once per member,
4502
+ `team.member.listening.unique`) and the call, so the application knows which audio is its own to
4503
+ open (`team.member.listening.assignmentId`, and one the member's tasks carry,
4504
+ `team.member.listening.assignment`). Every lead sees every entry: a silent lead is hidden from
4505
+ the agent, never from the other leads and managers watching the same member on other screens.
4506
+ A lead is on one member's call at a time (`team.listening.single`).
4444
4507
 
4445
4508
  **A lead listens while holding no call of their own.** Omni offers Listen to a lead whose voice
4446
4509
  channel is free, and a provider answers a `listen` from one whose channel is not `failed`. That
4447
4510
  includes a lead on a break: a lead working as lead alone is on a break of a working kind, and
4448
4511
  listens through it.
4449
4512
 
4450
- **Nothing reaches the member.** The member's task carries no trace of a listening lead in any
4451
- mode: not on `onCall`, not in the record. Whether coaching is announced to the agent is the
4452
- platform's business and travels on the audio, not on this wire.
4513
+ **A silent lead reaches nobody but the leads.** In `listen` and `coach` the member's task carries
4514
+ no trace of the lead: not on `onCall`, not in the record. Whether coaching is announced to the
4515
+ agent is the platform's business and travels on the audio, not on this wire. In `join-call`, and
4516
+ on a `join` answering a request, the lead is heard by everyone and is in the room: the task's
4517
+ `onCall` carries them as an `agent` by user id, on the member's desk and on every lead's copy of
4518
+ the task alike, and `leave` takes the entry off as it takes the channel off
4519
+ (`team.member.listening.heard`).
4453
4520
 
4454
4521
  ### A member waiting for a break
4455
4522
 
@@ -4917,7 +4984,7 @@ The current exceptions and their reasons are:
4917
4984
 
4918
4985
  | Case | Exception and reason |
4919
4986
  | --- | --- |
4920
- | Agent application display of provider deadlines (`expiresInSeconds`, `previewEndsInSeconds`, wrap deadline) | Each is seconds from the publication that carried it, counted down on the desk from receipt: the provider's own arithmetic, so no clock is compared and the transport delay is the only error. The countdown estimates the display only; it does not establish that the provider acted. Without usable time, show timing uncertainty. |
4987
+ | Agent application display of provider deadlines (`expiresInSeconds`, `previewEndsInSeconds`, `wrapEndsInSeconds`) | Each is seconds from the publication that carried it, counted down on the desk from receipt: the provider's own arithmetic, so no clock is compared and the transport delay is the only error. The countdown estimates the display only; it does not establish that the provider acted. Without usable time, show timing uncertainty. |
4921
4988
  | Agent application-triggered preview deadline | A bounded clock estimate with monotonic aging may schedule the agent application's Call command because the provider owns the deadline but the agent application owns the trigger. Do not trigger before the deadline is known to have passed; invalidate on clock discontinuity and reconcile when uncertain. |
4922
4989
  | Recording evidence expiry | A bounded observer-domain clock estimate with monotonic aging may assess freshness because observation and expiry belong to the recorder's clock. If time cannot be trusted, recording state is unknown; never renew evidence from receipt or replay. |
4923
4990
  | Unknown recording state | `observedAt` and `validUntil` are absent because there is no confirmed observation. The containing provider event still has its own `occurredAt`; that publication is not a recorder observation. |
@@ -4931,6 +4998,30 @@ must specify their uncertainty and validity; elapsed time should use monotonic a
4931
4998
  jump invalidates the estimate. The optional check below samples provider time without changing event timestamps. Report
4932
4999
  source-measured durations where required; timestamp subtraction is not a substitute.
4933
5000
 
5001
+ #### Every screen counts from the provider's clock
5002
+
5003
+ Two screens watching the same agent -- their own desk, their lead's, a manager's on another floor
5004
+ -- must show the same numbers. Delay between them is fine; arithmetic that differs is not. So
5005
+ nothing the desk shows is its own count, with one exception: the running duration of the active
5006
+ task or call, which a screen times itself from the moment it saw the call begin -- the
5007
+ `task-audio-started` on a softphone, the answer it sent on a desk phone -- and which two such
5008
+ screens may therefore disagree on, since their clocks differ. That is the whole exception. A call
5009
+ a screen found already up -- on a connect or reconnect snapshot, on a lead's list, after a reload
5010
+ -- is counted from the provider's `answered` step's `at` against the provider's clock, like every
5011
+ other duration, so a reloaded desk shows 12:40 beside the lead's 12:40 and not 0:05. And a leg
5012
+ that has closed shows the provider's `seconds` on every screen, never the count a screen kept.
5013
+ Everything else comes from the wire.
5014
+
5015
+ Durations off the active call -- how long a member has been `on-break` from their `since`, how
5016
+ long a lead has been listening, how long since `signedInAt` -- are counted from the provider's
5017
+ clock, never the screen's. The desk keeps the latest provider instant it has received -- a
5018
+ snapshot's `providerTime`, an envelope's `occurredAt`, a `checkTime` result -- with the monotonic
5019
+ elapsed time since receipt, and renders every such duration from that; a sample older than
5020
+ `maxSampleAgeMs` is refreshed with `checkTime`. Without a usable sample the desk shows the instant
5021
+ and no count. A provider that publishes any of these instants therefore declares `timeCheck`
5022
+ (`manifest.timeCheck.required`) and states `providerTime` on every snapshot
5023
+ (`snapshot.providerTime`). Two screens then differ by their delay and nothing else.
5024
+
4934
5025
  #### Optional periodic provider time checks and agent application estimates
4935
5026
 
4936
5027
  A provider may declare `Manifest.timeCheck: true` and implement `Connection.checkTime(request)`.
@@ -5068,6 +5159,11 @@ died; the agent application knows how to run a login. `recovery` joins the two:
5068
5159
  disposes the errored connection with `disconnect()` and calls `connect()` afresh, as for
5069
5160
  `reconnect`, and the fresh snapshot re-establishes state. The adapter tears nothing down itself
5070
5161
  and expects nothing to carry on: the connection that reported `error` is finished.
5162
+ - **`displaced`** — another client connected on this login and took it: the agent moved desks.
5163
+ Nothing revives this connection on its own -- Omni does not reconnect, or it would take the
5164
+ login straight back from the desk the agent just sat down at -- and the desk shows the agent
5165
+ they are signed in elsewhere, with a way to take the login back, which is a `connect()` that
5166
+ displaces the other. See **`Adapter.connect(context)`**.
5071
5167
 
5072
5168
  **Patience is the agent application's.** An adapter in `connecting` retries for as long as it takes and never
5073
5169
  has to decide when to stop. Omni owns giving up: after however long it chooses to wait, it may
@@ -5292,8 +5388,8 @@ turned the feature off (`event.team.features`).
5292
5388
  One member, whole, as they now stand: their availability, their tasks, their listening, their
5293
5389
  shift, their request. It replaces the member of that `id` on the lead's list, or adds them where
5294
5390
  the list did not carry them, so applying it twice changes nothing. It comes only after the whole
5295
- team has (`stream.team.baseline`), and it is what a member's hold, answer, mute or take-over
5296
- publishes to the lead.
5391
+ team has (`stream.team.baseline`), and it is what every publication of a member's task to the
5392
+ member publishes to the lead, as **Team leads** sets out: never fewer.
5297
5393
 
5298
5394
  ### `team-member-removed`
5299
5395
 
@@ -5316,6 +5412,13 @@ the `contacts` idle capability.
5316
5412
  Replaces this provider's complete scheduled-activity contribution. It is emitted only when the manifest
5317
5413
  declares the `calendar` idle capability.
5318
5414
 
5415
+ ### `shift-updated`
5416
+
5417
+ The agent's own day, whole, replacing `Snapshot.shift`: emitted whenever a total moves -- a task
5418
+ ended, a break ended, a sign-out -- and never to say nothing changed. It carries the same `Shift`
5419
+ the agent's lead sees for them on the team member list, so the two screens show one day. See
5420
+ **The agent's day is on the wire**.
5421
+
5319
5422
  ## Utilities
5320
5423
 
5321
5424
  ### `assignmentKey(providerId, assignmentId)`
@@ -5380,7 +5483,9 @@ carries that agent reports `team.member.self`, on a `team-member-updated` and a
5380
5483
  reports `team.unentitled`, and a team event to such a login reports `event.team.capability`; given
5381
5484
  `leadFeatures: true`, a snapshot without a team reports `team.required`, and given `leadFeatures: false`,
5382
5485
  anything of the team reaching the lead reports `team.unexpected` or `event.team.features`. Without them those rules are not checked, because they cannot be.
5383
- `exerciseAdapter` passes all three, holding the switch off until it has sent it.
5486
+ `exerciseAdapter` passes all three, holding the switch off until it has sent it. Given the manifest, a
5487
+ snapshot or event that carries an instant the desk renders as a running duration under a manifest
5488
+ without `timeCheck` reports `manifest.timeCheck.required`.
5384
5489
 
5385
5490
  `assertNoViolations(violations)` throws `ProtocolConformanceError` — which carries the full
5386
5491
  `violations` array — when the list is non-empty.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.103",
3
+ "version": "0.1.105",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",