@xema/omni-protocol 0.1.103 → 0.1.104

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";
@@ -1206,8 +1212,13 @@ export interface BreakRequest {
1206
1212
  */
1207
1213
  export type ForcedBreak = {
1208
1214
  by: UserId | "provider";
1209
- /** Optional expected time on break, in milliseconds from actual start. Advisory only. */
1210
- expectedDurationMs?: number;
1215
+ /**
1216
+ * How long the break is expected to run yet, in whole seconds from this publication, restated
1217
+ * on every publication that carries it so every screen counts down the same number from
1218
+ * receipt. Advisory only: nothing ends the break but the agent. Absent once it has run out, or
1219
+ * where none was expected.
1220
+ */
1221
+ expectedEndsInSeconds?: DurationSeconds;
1211
1222
  };
1212
1223
  export interface BreakState {
1213
1224
  status: BreakStatus;
@@ -1264,10 +1275,10 @@ export interface TeamMember {
1264
1275
  break?: Extract<BreakStatus, "awaiting-approval" | "granted" | "starting-after-task">;
1265
1276
  /** The member's open tasks, trimmed, kept current by the provider; `[]` when they hold none, omitted only where the provider cannot see them. */
1266
1277
  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;
1278
+ /** Every lead on this member's call, listening, coaching or joined, published to every lead alike; absent while none is. */
1279
+ listening?: MemberListening[];
1280
+ /** 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. */
1281
+ shift?: Shift;
1271
1282
  /** The member's standing ask for a lead on one of their calls; absent when they are not asking. */
1272
1283
  request?: MemberRequest;
1273
1284
  }
@@ -1310,11 +1321,14 @@ export type MemberTask<C extends Channel = Channel> = {
1310
1321
  audio?: never;
1311
1322
  });
1312
1323
  /**
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.
1324
+ * A lead on this member's call: who, which of the member's calls, in the mode they are heard,
1325
+ * since when. Every lead sees every entry; the application opens `openAudio` on the entry whose
1326
+ * `leadId` is the signed-in lead, and closes it when that entry goes. A lead who is heard --
1327
+ * `join-call` -- is also on the task's `onCall` as an `agent`; `listen` and `coach` leave no trace
1328
+ * on the member's task.
1316
1329
  */
1317
1330
  export interface MemberListening {
1331
+ leadId: UserId;
1318
1332
  assignmentId: AssignmentId;
1319
1333
  mode: ListeningMode;
1320
1334
  since: IsoTimestamp;
@@ -1326,11 +1340,13 @@ export interface ShiftEvent {
1326
1340
  kind: ShiftEventKind;
1327
1341
  }
1328
1342
  /**
1329
- * The member's own history for the day, about the person rather than any one call: when they
1343
+ * An agent's own history for the day, about the person rather than any one call: when they
1330
1344
  * 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.
1345
+ * provider knows it, and the day's sign-in, sign-out and break events, oldest first. Published
1346
+ * to the agent on their snapshot and on `shift-updated`, and to their lead on the team member
1347
+ * list: the same numbers from the same count.
1332
1348
  */
1333
- export interface MemberShift {
1349
+ export interface Shift {
1334
1350
  signedInAt: IsoTimestamp;
1335
1351
  signedOutAt?: IsoTimestamp;
1336
1352
  talkSeconds?: DurationSeconds;
@@ -1550,6 +1566,12 @@ export type PreferenceResult = {
1550
1566
  export interface Snapshot<C extends Channel = Channel> {
1551
1567
  transport: TransportStatus;
1552
1568
  loginId: string;
1569
+ /**
1570
+ * The provider's own instant of this read, required of a provider that declares `timeCheck`:
1571
+ * every duration the desk renders off the active call is counted from the provider's clock, and
1572
+ * this sample saves a `checkTime` for it. Every envelope's `occurredAt` is a sample too.
1573
+ */
1574
+ providerTime?: IsoTimestamp;
1553
1575
  break: BreakState;
1554
1576
  /** Every task currently owned by this agent for this provider. */
1555
1577
  tasks: Task<C>[];
@@ -1557,6 +1579,8 @@ export interface Snapshot<C extends Channel = Channel> {
1557
1579
  taskCount: number;
1558
1580
  contacts?: Contact[];
1559
1581
  calendar?: ScheduledActivity[];
1582
+ /** The agent's own day so far, as the provider counts it; replaced whole by `shift-updated`. Omitted where the provider cannot say. */
1583
+ shift?: Shift;
1560
1584
  team?: TeamMembers;
1561
1585
  }
1562
1586
  /**
@@ -1668,6 +1692,11 @@ export type ProviderEvent<C extends Channel = Channel> = {
1668
1692
  } | {
1669
1693
  type: "calendar-updated";
1670
1694
  calendar: ScheduledActivity[];
1695
+ }
1696
+ /** The agent's own day, whole, whenever a total moves: a task ended, a break ended. */
1697
+ | {
1698
+ type: "shift-updated";
1699
+ shift: Shift;
1671
1700
  };
1672
1701
  /**
1673
1702
  * 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();
@@ -1313,8 +1313,9 @@ function validateForcedBreak(value, path, into) {
1313
1313
  for (const field of ["endsAutomatically", "endsAt"]) {
1314
1314
  into.require(!Object.hasOwn(value, field), "break.forced.manualResume", `${path}.${field}`, "automatic ending fields are unsupported; the agent must resume manually");
1315
1315
  }
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");
1316
+ 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");
1317
+ if (value.expectedEndsInSeconds !== undefined)
1318
+ 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
1319
  }
1319
1320
  /** Checks break status against the full currently retained task set, after a transaction. */
1320
1321
  export function validateBreakStatus(state, tasks, path = "snapshot") {
@@ -1435,7 +1436,8 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
1435
1436
  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
1437
  }
1437
1438
  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`);
1439
+ // Every lead's entry is on the member; the one this command moves is the signed-in lead's.
1440
+ 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
1441
  }
1440
1442
  return into.violations;
1441
1443
  }
@@ -1631,10 +1633,19 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
1631
1633
  }
1632
1634
  validateTeamMemberInto(member, at, context, into);
1633
1635
  });
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");
1636
+ // One member's call at a time, per lead.
1637
+ const onCalls = new Map();
1638
+ for (const member of teamMembers.members) {
1639
+ if (!isPlainObject(member) || !Array.isArray(member.listening))
1640
+ continue;
1641
+ const leads = new Set(member.listening.filter(isPlainObject).map(entry => entry.leadId).filter((id) => typeof id === "string"));
1642
+ for (const leadId of leads)
1643
+ onCalls.set(leadId, (onCalls.get(leadId) ?? 0) + 1);
1644
+ }
1645
+ for (const [leadId, count] of onCalls) {
1646
+ if (count > 1)
1647
+ into.add("team.listening.single", `${path}.members`, `${leadId} is on ${count} members' calls: a lead is on one member's call at a time`);
1648
+ }
1638
1649
  }
1639
1650
  /** One member as the lead sees them, whole: on the list, and on `team-member-updated` alone. */
1640
1651
  export function validateTeamMember(member, path = "member", context = {}) {
@@ -1679,23 +1690,41 @@ function validateTeamMemberInto(member, at, context, into) {
1679
1690
  into.require(member.availability !== "signed-out" || member.tasks.length === 0, "team.member.tasks.availability", `${at}.tasks`, "a member signed out holds no task");
1680
1691
  }
1681
1692
  }
1682
- // The lead on this member's call, in the mode they are heard.
1693
+ // Every lead on this member's call, in the mode they are heard, published to every lead alike.
1683
1694
  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");
1695
+ if (!Array.isArray(member.listening)) {
1696
+ 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
1697
  }
1687
1698
  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`);
1699
+ into.require(member.listening.length > 0, "team.member.listening.empty", `${at}.listening`, "nobody on the call is absence, not an empty list");
1700
+ const leads = new Set();
1701
+ member.listening.forEach((entry, index) => {
1702
+ const here = `${at}.listening[${index}]`;
1703
+ if (!isPlainObject(entry)) {
1704
+ into.add("team.member.listening.entry", here, "each listening entry must be an object");
1705
+ return;
1706
+ }
1707
+ if (into.require(isUserId(entry.leadId), "team.member.listening.leadId", `${here}.leadId`, "a listening entry names the lead on the call, by user id")) {
1708
+ if (leads.has(entry.leadId))
1709
+ 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`);
1710
+ leads.add(entry.leadId);
1711
+ }
1712
+ const task = Array.isArray(member.tasks) ? member.tasks.find((task) => isPlainObject(task) && task.assignmentId === entry.assignmentId) : undefined;
1713
+ 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)) {
1714
+ into.require(task !== undefined, "team.member.listening.assignment", `${here}.assignmentId`, "the call the lead is on is one the member's tasks carry");
1715
+ }
1716
+ if (into.oneOf(entry.mode, LISTENING_MODES, "team.member.listening.mode", `${here}.mode`) && entry.mode === "join-call"
1717
+ && isPlainObject(task) && Array.isArray(task.onCall) && isUserId(entry.leadId)) {
1718
+ // A lead who is heard is in the room the member sees; a silent one is not.
1719
+ 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`);
1720
+ }
1721
+ into.timestamp(entry.since, "team.member.listening.since", `${here}.since`);
1722
+ });
1694
1723
  }
1695
1724
  }
1696
1725
  // The member's own history for the day: about the person, not any one call.
1697
1726
  if (member.shift !== undefined)
1698
- validateShiftInto(member.shift, `${at}.shift`, into);
1727
+ validateShiftInto(member.shift, `${at}.shift`, into, "team.member.shift");
1699
1728
  // The member's standing ask for a lead, on one of the calls they hold.
1700
1729
  if (member.request !== undefined) {
1701
1730
  if (!isPlainObject(member.request)) {
@@ -1721,6 +1750,18 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1721
1750
  const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
1722
1751
  const levels = context.levels ?? manifestLevels(manifest);
1723
1752
  into.oneOf(snapshot.transport, TRANSPORT_STATUSES, "snapshot.transport", `${path}.transport`);
1753
+ // The provider's own instant of the read: required where the provider has a clock to state, and
1754
+ // a clock nobody declared is not one the desk may count from.
1755
+ if (isPlainObject(manifest) && manifest.timeCheck === true) {
1756
+ into.timestamp(snapshot.providerTime, "snapshot.providerTime", `${path}.providerTime`);
1757
+ }
1758
+ else if (snapshot.providerTime !== undefined) {
1759
+ into.add("snapshot.providerTime.unexpected", `${path}.providerTime`, "providerTime is the clock of a provider that declares timeCheck; declare it, or send none");
1760
+ }
1761
+ requireClockInto(snapshot, manifest, path, into);
1762
+ // The agent's own day, as the provider counts it: the same numbers their lead sees.
1763
+ if (snapshot.shift !== undefined)
1764
+ validateShiftInto(snapshot.shift, `${path}.shift`, into, "shift");
1724
1765
  if (into.filled(snapshot.loginId, "snapshot.loginId", `${path}.loginId`, "a snapshot needs the login id it belongs to")
1725
1766
  && context.loginId !== undefined) {
1726
1767
  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 +1935,9 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1894
1935
  return into.violations;
1895
1936
  }
1896
1937
  const at = `${path}.event`;
1938
+ // A snapshot event is read by validateSnapshot, which asks the clock question of it there.
1939
+ if (event.type !== "snapshot")
1940
+ requireClockInto(event, manifest, at, into);
1897
1941
  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
1942
  switch (event.type) {
1899
1943
  case "snapshot":
@@ -2004,6 +2048,9 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
2004
2048
  event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into, levels));
2005
2049
  }
2006
2050
  break;
2051
+ case "shift-updated":
2052
+ validateShiftInto(event.shift, `${at}.shift`, into, "shift");
2053
+ break;
2007
2054
  case "calendar-updated":
2008
2055
  into.require(idle.calendar === true, "event.calendar.capability", `${at}.calendar`, "calendar-updated requires the calendar idle capability");
2009
2056
  if (!Array.isArray(event.calendar)) {
@@ -2055,38 +2102,65 @@ function validatePreferencesInto(value, path, into, levels) {
2055
2102
  });
2056
2103
  }
2057
2104
  /** 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) {
2105
+ /** Whether a publication carries an instant the desk renders as a running duration, which the provider's clock reads. */
2106
+ function carriesRunningInstant(value) {
2107
+ if (!isPlainObject(value))
2108
+ return false;
2109
+ if (isPlainObject(value.shift) && value.shift.signedInAt !== undefined)
2110
+ return true;
2111
+ const tasks = [
2112
+ ...(Array.isArray(value.tasks) ? value.tasks : []),
2113
+ ...(isPlainObject(value.task) ? [value.task] : []),
2114
+ ];
2115
+ if (tasks.some(task => isPlainObject(task) && Array.isArray(task.onCall) && task.onCall.some((who) => isPlainObject(who) && who.since !== undefined)))
2116
+ return true;
2117
+ const members = [
2118
+ ...(isPlainObject(value.team) && Array.isArray(value.team.members) ? value.team.members : []),
2119
+ ...(isPlainObject(value.member) ? [value.member] : []),
2120
+ ];
2121
+ return members.some(member => isPlainObject(member) && (member.since !== undefined
2122
+ || (Array.isArray(member.listening) && member.listening.some((entry) => isPlainObject(entry) && entry.since !== undefined))
2123
+ || (isPlainObject(member.shift) && member.shift.signedInAt !== undefined)
2124
+ || (Array.isArray(member.tasks) && member.tasks.some((task) => isPlainObject(task) && Array.isArray(task.onCall) && task.onCall.some((who) => isPlainObject(who) && who.since !== undefined)))));
2125
+ }
2126
+ /** A running duration is counted from the provider's clock: a provider that publishes one declares timeCheck. */
2127
+ function requireClockInto(value, manifest, path, into) {
2128
+ if (!isPlainObject(manifest) || manifest.timeCheck === true || !carriesRunningInstant(value))
2129
+ return;
2130
+ 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");
2131
+ }
2132
+ function validateShiftInto(value, path, into, rule) {
2059
2133
  if (!isPlainObject(value)) {
2060
- into.add("team.member.shift.shape", path, "shift must be an object when present");
2134
+ into.add(`${rule}.shape`, path, "shift must be an object when present");
2061
2135
  return;
2062
2136
  }
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");
2137
+ const signedIn = into.timestamp(value.signedInAt, `${rule}.signedInAt`, `${path}.signedInAt`);
2138
+ if (value.signedOutAt !== undefined && into.timestamp(value.signedOutAt, `${rule}.signedOutAt`, `${path}.signedOutAt`) && signedIn) {
2139
+ into.require(Date.parse(value.signedOutAt) >= Date.parse(value.signedInAt), `${rule}.signedOutAt.order`, `${path}.signedOutAt`, "a member signs out after signing in");
2066
2140
  }
2067
2141
  for (const field of ["talkSeconds", "holdSeconds", "breakSeconds"]) {
2068
2142
  if (value[field] !== undefined)
2069
2143
  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
2144
  }
2071
2145
  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");
2146
+ 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
2147
  if (value.events !== undefined) {
2074
2148
  if (!Array.isArray(value.events)) {
2075
- into.add("team.member.shift.events.shape", `${path}.events`, "events must be an array when present");
2149
+ into.add(`${rule}.events.shape`, `${path}.events`, "events must be an array when present");
2076
2150
  return;
2077
2151
  }
2078
2152
  let previous;
2079
2153
  value.events.forEach((event, index) => {
2080
2154
  const at = `${path}.events[${index}]`;
2081
2155
  if (!isPlainObject(event)) {
2082
- into.add("team.member.shift.event.shape", at, "each shift event must be an object");
2156
+ into.add(`${rule}.event.shape`, at, "each shift event must be an object");
2083
2157
  return;
2084
2158
  }
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`)) {
2159
+ into.oneOf(event.kind, SHIFT_EVENT_KINDS, `${rule}.event.kind`, `${at}.kind`);
2160
+ if (into.timestamp(event.at, `${rule}.event.at`, `${at}.at`)) {
2087
2161
  const instant = Date.parse(event.at);
2088
2162
  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");
2163
+ into.add(`${rule}.events.order`, `${at}.at`, "shift events are oldest first; this event is earlier than the one before it");
2090
2164
  previous = instant;
2091
2165
  }
2092
2166
  });
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
 
@@ -933,7 +935,7 @@ type BreakRequest = {
933
935
 
934
936
  type ForcedBreak = {
935
937
  by: UserId | "provider";
936
- expectedDurationMs?: number;
938
+ expectedEndsInSeconds?: DurationSeconds;
937
939
  };
938
940
 
939
941
  type BreakState = {
@@ -975,8 +977,8 @@ type TeamMember = {
975
977
  since?: IsoTimestamp;
976
978
  break?: Extract<BreakStatus, "awaiting-approval" | "granted" | "starting-after-task">;
977
979
  tasks?: MemberTask[];
978
- listening?: MemberListening;
979
- shift?: MemberShift;
980
+ listening?: MemberListening[];
981
+ shift?: Shift;
980
982
  request?: MemberRequest;
981
983
  };
982
984
 
@@ -1006,6 +1008,7 @@ type MemberTask<C extends Channel = Channel> = {
1006
1008
  : { onCall?: never; leadAssist?: never; takenOver?: never; audio?: never });
1007
1009
 
1008
1010
  type MemberListening = {
1011
+ leadId: UserId;
1009
1012
  assignmentId: AssignmentId;
1010
1013
  mode: ListeningMode;
1011
1014
  since: IsoTimestamp;
@@ -1018,7 +1021,7 @@ type ShiftEvent = {
1018
1021
  kind: ShiftEventKind;
1019
1022
  };
1020
1023
 
1021
- type MemberShift = {
1024
+ type Shift = {
1022
1025
  signedInAt: IsoTimestamp;
1023
1026
  signedOutAt?: IsoTimestamp;
1024
1027
  talkSeconds?: DurationSeconds;
@@ -1134,7 +1137,8 @@ type ProviderEvent =
1134
1137
  | { type: "team-member-removed"; memberId: UserId }
1135
1138
  | { type: "team-policies-updated"; policies: TeamPolicies }
1136
1139
  | { type: "contacts-updated"; contacts: Contact[] }
1137
- | { type: "calendar-updated"; calendar: ScheduledActivity[] };
1140
+ | { type: "calendar-updated"; calendar: ScheduledActivity[] }
1141
+ | { type: "shift-updated"; shift: Shift };
1138
1142
 
1139
1143
  type ProviderEventEnvelope = {
1140
1144
  id: string;
@@ -1626,7 +1630,7 @@ compile time.
1626
1630
  | `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
1631
  | `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
1632
  | `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. |
1633
+ | `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
1634
  | `timestampAuthority` | Optional `"provider"`: provider timestamps are final; agent application instants are advisory. Omission makes no trust promise. |
1631
1635
  | `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
1636
  | `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`**. |
@@ -2142,11 +2146,13 @@ a capability it agrees with the login: a lead's snapshot carries `team`, nobody
2142
2146
  | --- | --- |
2143
2147
  | `transport` | Current `TransportStatus` — whether this provider's transport can serve the login. Defined under **`transport-status`**. |
2144
2148
  | `loginId` | Identity of this login. It must match the connection context. |
2149
+ | `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
2150
  | `break` | Complete break state, including status, whether the agent may ask, reasons, retry details, and any forced break. |
2146
2151
  | `tasks` | Complete set of tasks currently offered to or owned by this agent. |
2147
2152
  | `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
2153
  | `contacts` | Required complete contact contribution when the manifest declares `contacts`; `[]` clears it. Omitted only when it does not. |
2149
2154
  | `calendar` | Required complete calendar contribution when the manifest declares `calendar`; `[]` clears it. Omitted only when it does not. |
2155
+ | `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
2156
  | `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
2157
 
2152
2158
  ## Live connection
@@ -2798,6 +2804,11 @@ Migration from the earlier spellings:
2798
2804
  | 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
2805
  | a forced break requesting breaks on the other providers | `setCapacity({ count: 0 })` on every other usable provider (`assertForcedBreakStopsTheRest`) |
2800
2806
  | end-forced-break on a break the platform imposed | refused: `team.command.endForcedBreak.provider`, `omni.break-forced-by-provider` |
2807
+ | ForcedBreak.expectedDurationMs | `expectedEndsInSeconds`: seconds left, restated (`break.forced.expectedEndsInSeconds`, `.expectedDurationMs.renamed`); the command keeps `expectedDurationMs` |
2808
+ | 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 |
2809
+ | TeamMember.listening as this lead's one entry | `MemberListening[]`, every lead's, each with `leadId` (`team.member.listening.leadId`, `.unique`, `.empty`, `.entry`, `.heard`) |
2810
+ | a member's history trimmed on the lead's list | the record whole, as the member's desk holds it |
2811
+ | 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`) |
2801
2812
 
2802
2813
  Update producers, consumers, saved task snapshots, and validation-rule assertions together.
2803
2814
  History and report rule names use `history` and `historyReport`; assignment rules use
@@ -3747,14 +3758,14 @@ The team break command formerly named release is now `end-forced-break`, and its
3747
3758
  `team.break.command.endForcedBreak`. The former release command and the interim end spelling are rejected without aliases.
3748
3759
  The agent’s `endBreak` now applies to forced breaks as well as requested breaks.
3749
3760
 
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.
3761
+ `ForcedBreak` names who forced the break and may say how long it is expected to run yet, as
3762
+ `expectedEndsInSeconds`: whole seconds from the publication that carries it, restated on every
3763
+ publication, so every screen -- the agent's, reloaded or not, and every lead's -- counts down the
3764
+ same number from receipt (`break.forced.expectedEndsInSeconds`). The duration the lead asked for
3765
+ travels on the `force-break` command alone; the state says what is left, and drops the field once
3766
+ it has run out. The agent must explicitly resume when ready. The retired endsAutomatically and
3767
+ endsAt fields are rejected, including when an expectation is also provided. Who forced it is
3768
+ `by`, as **A forced break is the provider's act** below sets out.
3758
3769
 
3759
3770
  Omni resolves the name to show with `getUserDetails()`, so a provider sends the identifier and never
3760
3771
  a display name.
@@ -3769,16 +3780,16 @@ For example:
3769
3780
  ```ts
3770
3781
  forced: {
3771
3782
  by: "manager-1042",
3772
- expectedDurationMs: 600000
3783
+ expectedEndsInSeconds: 600
3773
3784
  }
3774
3785
  ```
3775
3786
 
3776
3787
  The agent application keeps **Resume** available for an agent on a forced break. The agent explicitly chooses
3777
3788
  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.
3789
+ work. The expectation is whole seconds still to run, restated on every publication; omission
3790
+ means none was stated, or it has run out. Neither its reaching zero nor an overdue indication
3791
+ authorizes the agent application or provider to end the break, restore availability, or route
3792
+ work to the agent.
3782
3793
 
3783
3794
  **A forced break is the provider's act.** A lead's `force-break` is a command to the provider,
3784
3795
  which forces the break on the member; `by` names the lead who asked it to. A break the platform
@@ -3802,9 +3813,9 @@ must correlate resumption to the authenticated agent's action; a state-only orde
3802
3813
  cannot establish the cause of a transition. The usual multi-provider end/reconciliation flow
3803
3814
  still applies, and work resumes only on confirmed provider state.
3804
3815
 
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.
3816
+ An agent application counts `expectedEndsInSeconds` down from receipt, on every publication that
3817
+ carries it, and shows nothing of it where the provider sends none. It invents no start and no
3818
+ return time, and nothing acts on the number reaching zero.
3808
3819
 
3809
3820
  A break applies to the **agent**, not to one provider. When a provider forces one, Omni stops the
3810
3821
  agent everywhere else at once, and not by asking: it states `setCapacity({ count: 0 })` on every
@@ -4210,10 +4221,10 @@ computes nothing. See **`team-updated`** and its companions.
4210
4221
  | `availability` | Required. What the member is doing now. |
4211
4222
  | `since` | Optional. When the current `availability` began — not when they signed in, and not when the team member list was read. |
4212
4223
  | `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`). |
4224
+ | `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, 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; only 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. |
4225
+ | `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
4226
  | `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. |
4227
+ | `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
4228
 
4218
4229
  Each availability value means one thing:
4219
4230
 
@@ -4236,11 +4247,19 @@ by it, as a colleague signing in is. `team-member-removed` takes one off. Never
4236
4247
  member, never a task of theirs on its own: a hold on a member's desk is that member republished
4237
4248
  whole on the lead's, and nothing else on the lead's screen moves.
4238
4249
 
4250
+ **The agent's day is on the wire.** The lead sees `shift` totals as the provider counts them, and
4251
+ the agent sees the same: `Snapshot.shift` and `shift-updated` carry the agent's own day in the same
4252
+ shape, from the same count, so "calls today: 12" is one number on both screens. The desk computes
4253
+ nothing of the day; `tasksHandled` moves when a task ends and `talkSeconds` when a leg closes, and
4254
+ the active call's running time is the screen's own until then, as **Every screen counts from the
4255
+ provider's clock** sets out.
4256
+
4239
4257
  **Omit `since` rather than inventing one.** Omni renders it as a duration, so a timestamp
4240
4258
  synthesised from the adapter's own clock at seed time reads as "on task for 0 seconds" for
4241
4259
  everybody — worse than showing nothing, because it looks like data. Send it only when the provider
4242
4260
  knows when the state actually began. It times the current `availability`, so it moves every time
4243
- that value does.
4261
+ that value does. And it is counted from the provider's clock, never the screen's, so a provider
4262
+ that publishes it declares `timeCheck` -- see **Every screen counts from the provider's clock**.
4244
4263
 
4245
4264
  **The login is the permission.** A team member list goes to a login that declares
4246
4265
  `capabilities.lead`, once the team feature is on, and to nobody else. Omni never decides who
@@ -4333,9 +4352,10 @@ executeTeam({ command: { type: "decline", memberId: "A-1", reason: "In a call" }
4333
4352
  ```
4334
4353
 
4335
4354
  **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
4355
+ `leadAssist: { stage: "joined", leadId }` and its `onCall` gains the lead as an `agent`, since a
4356
+ lead who is heard is in the room; the member on the team member list carries
4357
+ `listening: [{ leadId, assignmentId, mode: "join-call", since }]`, and the lead's audio opens on
4358
+ 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
4359
  work assigned to them.
4340
4360
 
4341
4361
  **A lead is on one member's call at a time.** A `join` from a lead already on a call -- their own
@@ -4403,10 +4423,11 @@ history. The three modes determine who hears the lead:
4403
4423
  All three come with the `lead` flag; a centre reserves none of them per lead.
4404
4424
 
4405
4425
  **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
4426
+ listening follows the provider's published state exactly as a task's does: the entry naming this
4427
+ lead in `listening` on the member is the provider's word that the lead's channel is in the call.
4428
+ When it appears, the application opens `openAudio({ assignmentId })` on the lead's connection for
4429
+ the call it names, and plays the `CallAudio` returned; when it goes, the application closes it.
4430
+ Another lead's entry opens nothing: it is shown, and that is all. Nothing else says
4410
4431
  when the lead's audio is up, and a reload reopens it from the snapshot's team member list as a task
4411
4432
  carried with `audio: "started"` is reopened.
4412
4433
 
@@ -4417,13 +4438,15 @@ The flow, end to end:
4417
4438
  // 2. The lead's channel joins it in silence. Conditions: the lead's voice channel is free --
4418
4439
  // not on a call, not listening elsewhere. Capacity is not involved: nothing is assigned.
4419
4440
  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.
4441
+ // 3. The provider publishes the member whole on team-member-updated, to every lead; the member carries
4442
+ // listening: [{ leadId: "L-9", assignmentId: "alloc-42", mode: "listen", since }]
4443
+ // -- L-9's application opens openAudio({ assignmentId: "alloc-42" }) on its own connection; every
4444
+ // other lead's shows L-9 on the call and opens nothing.
4445
+ // 4. The lead changes how they are heard; the member is published again with the entry's new mode. The audio stays open.
4446
+ // join-call is heard by everyone, so the member's task is also republished with L-9 on onCall as an agent.
4424
4447
  executeTeam({ command: { type: "coach", memberId: "A-1" } })
4425
4448
  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.
4449
+ // 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
4450
  executeTeam({ command: { type: "leave", memberId: "A-1" } })
4428
4451
  // 6. The member's call ends: the same as leave, from the provider's side.
4429
4452
  // 7. The lead takes the call: the member loses listening no later than the offer, the application
@@ -4434,22 +4457,29 @@ executeTeam({ command: { type: "take-over-call", memberId: "A-1" } })
4434
4457
  // 8. A join on the member's request is the same flow entered at step 3 with mode: "join-call",
4435
4458
  // and the member's task shows leadAssist: joined.
4436
4459
  // 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.
4460
+ // still carries this lead's entry in listening on the member, so the audio is reopened.
4438
4461
  ```
4439
4462
 
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`).
4463
+ **`listening` on the member is the state**, one entry per lead on the call, restated on every
4464
+ change of mode, naming the lead (`team.member.listening.leadId`, once per member,
4465
+ `team.member.listening.unique`) and the call, so the application knows which audio is its own to
4466
+ open (`team.member.listening.assignmentId`, and one the member's tasks carry,
4467
+ `team.member.listening.assignment`). Every lead sees every entry: a silent lead is hidden from
4468
+ the agent, never from the other leads and managers watching the same member on other screens.
4469
+ A lead is on one member's call at a time (`team.listening.single`).
4444
4470
 
4445
4471
  **A lead listens while holding no call of their own.** Omni offers Listen to a lead whose voice
4446
4472
  channel is free, and a provider answers a `listen` from one whose channel is not `failed`. That
4447
4473
  includes a lead on a break: a lead working as lead alone is on a break of a working kind, and
4448
4474
  listens through it.
4449
4475
 
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.
4476
+ **A silent lead reaches nobody but the leads.** In `listen` and `coach` the member's task carries
4477
+ no trace of the lead: not on `onCall`, not in the record. Whether coaching is announced to the
4478
+ agent is the platform's business and travels on the audio, not on this wire. In `join-call`, and
4479
+ on a `join` answering a request, the lead is heard by everyone and is in the room: the task's
4480
+ `onCall` carries them as an `agent` by user id, on the member's desk and on every lead's copy of
4481
+ the task alike, and `leave` takes the entry off as it takes the channel off
4482
+ (`team.member.listening.heard`).
4453
4483
 
4454
4484
  ### A member waiting for a break
4455
4485
 
@@ -4931,6 +4961,24 @@ must specify their uncertainty and validity; elapsed time should use monotonic a
4931
4961
  jump invalidates the estimate. The optional check below samples provider time without changing event timestamps. Report
4932
4962
  source-measured durations where required; timestamp subtraction is not a substitute.
4933
4963
 
4964
+ #### Every screen counts from the provider's clock
4965
+
4966
+ Two screens watching the same agent -- their own desk, their lead's, a manager's on another floor
4967
+ -- must show the same numbers. Delay between them is fine; arithmetic that differs is not. So
4968
+ nothing the desk shows is its own count, with one exception: the running duration of the active
4969
+ task or call, which each screen times from the moment it saw the call begin, and which two screens
4970
+ may therefore disagree on, since their clocks differ. Everything else comes from the wire.
4971
+
4972
+ Durations off the active call -- how long a member has been `on-break` from their `since`, how
4973
+ long a lead has been listening, how long since `signedInAt` -- are counted from the provider's
4974
+ clock, never the screen's. The desk keeps the latest provider instant it has received -- a
4975
+ snapshot's `providerTime`, an envelope's `occurredAt`, a `checkTime` result -- with the monotonic
4976
+ elapsed time since receipt, and renders every such duration from that; a sample older than
4977
+ `maxSampleAgeMs` is refreshed with `checkTime`. Without a usable sample the desk shows the instant
4978
+ and no count. A provider that publishes any of these instants therefore declares `timeCheck`
4979
+ (`manifest.timeCheck.required`) and states `providerTime` on every snapshot
4980
+ (`snapshot.providerTime`). Two screens then differ by their delay and nothing else.
4981
+
4934
4982
  #### Optional periodic provider time checks and agent application estimates
4935
4983
 
4936
4984
  A provider may declare `Manifest.timeCheck: true` and implement `Connection.checkTime(request)`.
@@ -5316,6 +5364,13 @@ the `contacts` idle capability.
5316
5364
  Replaces this provider's complete scheduled-activity contribution. It is emitted only when the manifest
5317
5365
  declares the `calendar` idle capability.
5318
5366
 
5367
+ ### `shift-updated`
5368
+
5369
+ The agent's own day, whole, replacing `Snapshot.shift`: emitted whenever a total moves -- a task
5370
+ ended, a break ended, a sign-out -- and never to say nothing changed. It carries the same `Shift`
5371
+ the agent's lead sees for them on the team member list, so the two screens show one day. See
5372
+ **The agent's day is on the wire**.
5373
+
5319
5374
  ## Utilities
5320
5375
 
5321
5376
  ### `assignmentKey(providerId, assignmentId)`
@@ -5380,7 +5435,9 @@ carries that agent reports `team.member.self`, on a `team-member-updated` and a
5380
5435
  reports `team.unentitled`, and a team event to such a login reports `event.team.capability`; given
5381
5436
  `leadFeatures: true`, a snapshot without a team reports `team.required`, and given `leadFeatures: false`,
5382
5437
  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.
5438
+ `exerciseAdapter` passes all three, holding the switch off until it has sent it. Given the manifest, a
5439
+ snapshot or event that carries an instant the desk renders as a running duration under a manifest
5440
+ without `timeCheck` reports `manifest.timeCheck.required`.
5384
5441
 
5385
5442
  `assertNoViolations(violations)` throws `ProtocolConformanceError` — which carries the full
5386
5443
  `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.104",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",