@xema/omni-protocol 0.1.30 → 0.1.32

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
@@ -113,6 +113,12 @@ export interface Manifest<C extends Channel = Channel> {
113
113
  taskTypePresentation?: Record<string, TaskTypePresentation>;
114
114
  /** The organisation's whole ladder, stated outright, `person` included. Omitted for the typical four, `DEFAULT_LEVELS`. */
115
115
  orgLevels?: LevelDeclaration[];
116
+ /**
117
+ * The provider takes running reports of a host-performed step -- `recordStep` with `seconds`
118
+ * so far and no `ended`. Absent, the host sends exactly two reports per leg, when it began and
119
+ * when it ended, and a running report is refused: what a provider never asked for never crosses.
120
+ */
121
+ runningStepReports?: true;
116
122
  }
117
123
  export interface SecretStore {
118
124
  get(key: string): Promise<string | undefined>;
@@ -493,6 +499,24 @@ export type TaskAttribute = TaskAttributeBase & ({
493
499
  at: IsoTimestamp;
494
500
  });
495
501
  export type HandlingStep = "queued" | "offered" | "answered" | "held" | "muted" | "transferred" | "conferenced" | "unanswered";
502
+ /**
503
+ * The call record: the steps that brought the task here, one entry per occurrence and oldest
504
+ * first, and what they add up to before this agent -- stated by the provider from its own record,
505
+ * never summed by a desk from instants. Each total is present when the provider knows it and
506
+ * absent when it does not; a plausible nought is the fallback the no-fallbacks rule forbids. The
507
+ * record rides on the task and is replaced with it, so a late entry corrects the sums.
508
+ */
509
+ export interface TaskHandlingHistory {
510
+ steps: TaskHandlingStep[];
511
+ /** Seconds others spent handling it before this agent. */
512
+ handleSeconds?: DurationSeconds;
513
+ /** Seconds the caller spent on hold at others' hands. */
514
+ holdSeconds?: DurationSeconds;
515
+ /** Seconds waiting before anyone answered. */
516
+ queueSeconds?: DurationSeconds;
517
+ /** How many times the task changed hands. */
518
+ transfers?: number;
519
+ }
496
520
  export interface TaskHandlingStep {
497
521
  step: HandlingStep;
498
522
  at: IsoTimestamp;
@@ -617,7 +641,7 @@ export type Task<C extends Channel = Channel> = {
617
641
  /** The identifier an agent reads back to a customer, where the provider has one. */
618
642
  reference?: string;
619
643
  attributes?: TaskAttribute[];
620
- handlingHistory?: TaskHandlingStep[];
644
+ handlingHistory?: TaskHandlingHistory;
621
645
  } & TaskCompletion & (C extends "voice" ? {
622
646
  consultation?: TaskConsultation;
623
647
  lead?: TaskLead;
@@ -1025,6 +1049,31 @@ export type SetPreferenceRequest = {
1025
1049
  id: PreferenceId;
1026
1050
  inherit: true;
1027
1051
  };
1052
+ /**
1053
+ * The host's report of a handling leg it performed itself -- a mute, which Omni does rather than
1054
+ * the provider -- so the provider's record has an account of it. Keyed by `step` and `at`: the
1055
+ * same entry is reported when it begins, as often as the host cares to while it runs, and once
1056
+ * more with `ended`, when `seconds` is the final duration. The host is the authority for the
1057
+ * legs it performs, so `seconds` may say how long so far at any time; the end is stated, never
1058
+ * inferred from a number's presence. What the adapter forwards upstream, and how often, is its own.
1059
+ */
1060
+ export type HandlingReport = {
1061
+ taskId: TaskId;
1062
+ step: HandlingStep;
1063
+ at: IsoTimestamp;
1064
+ } & ({
1065
+ ended: true;
1066
+ seconds: DurationSeconds;
1067
+ } | {
1068
+ ended?: never;
1069
+ seconds?: DurationSeconds;
1070
+ });
1071
+ export type HandlingReportResult = {
1072
+ status: "recorded";
1073
+ } | {
1074
+ status: "failed";
1075
+ failure: ProtocolFailure;
1076
+ };
1028
1077
  export type PreferenceResult = {
1029
1078
  status: "applied";
1030
1079
  } | {
@@ -1168,6 +1217,8 @@ export interface Connection<C extends Channel = Channel> {
1168
1217
  openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
1169
1218
  /** Required when the login declares `capabilities.preferences`: the person's own choice, kept by the provider and republished as `authenticated`. */
1170
1219
  setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
1220
+ /** Records a handling leg the host performed. Required of a connection whose tasks may declare `mute`. */
1221
+ recordStep?(report: HandlingReport): Promise<HandlingReportResult>;
1171
1222
  }
1172
1223
  export interface Adapter<C extends Channel = Channel> {
1173
1224
  manifest: Manifest<C>;
package/dist/testing.js CHANGED
@@ -52,7 +52,7 @@ function observeTask(value, seen) {
52
52
  seen.add("task.browsers");
53
53
  if (some(value.attributes))
54
54
  seen.add("task.attributes");
55
- if (some(value.handlingHistory))
55
+ if (isRecord(value.handlingHistory) && some(value.handlingHistory.steps))
56
56
  seen.add("task.handlingHistory");
57
57
  if (value.consultation !== undefined)
58
58
  seen.add("task.consultation");
@@ -289,6 +289,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
289
289
  }
290
290
  if (eventNamesUsers(envelope))
291
291
  requireMethod(live, "describeUsers", "an event publishes a UserId");
292
+ if (eventDeclaresMute(envelope))
293
+ requireMethod(live, "recordStep", "a task declares mute, which the host performs and must have somewhere to record");
292
294
  // Cross-event rules apply once the stream has a beginning: the connect snapshot.
293
295
  if (seeded)
294
296
  violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
@@ -308,6 +310,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
308
310
  requireCapabilityMethods(live, current().capabilities);
309
311
  if (publishesUserIds(snapshot))
310
312
  requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
313
+ if (isRecord(snapshot) && Array.isArray(snapshot.tasks) && snapshot.tasks.some(taskDeclaresMute)) {
314
+ requireMethod(live, "recordStep", "a task declares mute, which the host performs and must have somewhere to record");
315
+ }
311
316
  // Capacity is stated, not requested: nothing may be allocated until it is, so a connection
312
317
  // that will not accept one is a connection nothing can be given to.
313
318
  // The guide's obligation on a voice adapter: consult the host before declaring the agent
@@ -398,9 +403,21 @@ function publishesUserIds(snapshot) {
398
403
  return snapshot.tasks.some(taskNamesUsers);
399
404
  }
400
405
  const teamNamesUsers = (team) => isRecord(team) && (some(team.members) || some(team.requests));
401
- const taskNamesUsers = (task) => isRecord(task) && ((Array.isArray(task.handlingHistory) && task.handlingHistory.some(step => isRecord(step) && step.by !== undefined)) ||
406
+ const taskNamesUsers = (task) => isRecord(task) && ((isRecord(task.handlingHistory) && Array.isArray(task.handlingHistory.steps) && task.handlingHistory.steps.some(step => isRecord(step) && step.by !== undefined)) ||
402
407
  (isRecord(task.lead) && task.lead.leadId !== undefined) ||
403
408
  isRecord(task.assisting));
409
+ /** Mute is the leg the host performs, so a task that allows it obliges the provider to take the record. */
410
+ const taskDeclaresMute = (task) => isRecord(task) && isRecord(task.capabilities) && task.capabilities.mute !== undefined;
411
+ function eventDeclaresMute(envelope) {
412
+ const event = isRecord(envelope) ? envelope.event : undefined;
413
+ if (!isRecord(event))
414
+ return false;
415
+ if (event.type === "task-offered" || event.type === "task-updated")
416
+ return taskDeclaresMute(event.task);
417
+ if (event.type === "snapshot" && isRecord(event.snapshot) && Array.isArray(event.snapshot.tasks))
418
+ return event.snapshot.tasks.some(taskDeclaresMute);
419
+ return false;
420
+ }
404
421
  /** Whether an event publishes a `UserId`, on a roster, a task, or the snapshot a reconnect carries. */
405
422
  function eventNamesUsers(envelope) {
406
423
  const event = isRecord(envelope) ? envelope.event : undefined;
@@ -49,9 +49,15 @@ export declare function validateEventEnvelope(envelope: unknown, manifest: unkno
49
49
  * promise withheld is an absent key, never a false one, exactly as a capability is.
50
50
  */
51
51
  export declare function validateHostGuarantees(guarantees: unknown, path?: string): ProtocolViolation[];
52
+ /**
53
+ * What the host reports of a leg it performed, as an adapter may validate it before forwarding:
54
+ * a task, a step, when it began, how long so far if the host says, and an explicit end that
55
+ * carries the final duration.
56
+ */
57
+ export declare function validateHandlingReport(report: unknown, path?: string, manifest?: unknown): ProtocolViolation[];
52
58
  export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
53
59
  /** The connection methods whose results `validateResult` knows. */
54
- export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia" | "setPreference" | "executeTeamPolicy";
60
+ export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia" | "setPreference" | "recordStep" | "executeTeamPolicy";
55
61
  /**
56
62
  * Validates what a connection method answered. A result is untrusted for the same reason a
57
63
  * snapshot is: it comes from an adapter that may be compiled against another version, and Omni
@@ -312,6 +312,9 @@ export function validateManifest(manifest, path = "manifest") {
312
312
  }
313
313
  }
314
314
  }
315
+ if (manifest.runningStepReports !== undefined) {
316
+ into.require(manifest.runningStepReports === true, "manifest.runningStepReports", `${path}.runningStepReports`, "runningStepReports is declared by presence, as true; a provider that takes begin and end only omits it");
317
+ }
315
318
  if (manifest.orgLevels !== undefined) {
316
319
  if (!Array.isArray(manifest.orgLevels)) {
317
320
  into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
@@ -628,18 +631,41 @@ function validateTaskAttributes(value, path, into) {
628
631
  function validateHandlingHistory(value, path, into) {
629
632
  if (value === undefined)
630
633
  return;
631
- if (!Array.isArray(value)) {
632
- into.add("task.handlingHistory.shape", path, "handlingHistory must be an array when present");
634
+ if (!isPlainObject(value)) {
635
+ into.add("task.handlingHistory.shape", path, "handlingHistory must be an object with its steps when present");
633
636
  return;
634
637
  }
635
- value.forEach((entry, index) => {
636
- const at = `${path}[${index}]`;
638
+ // What the record adds up to before this agent: each total present when the provider knows it
639
+ // and absent when it does not, never a plausible nought.
640
+ for (const field of ["handleSeconds", "holdSeconds", "queueSeconds"]) {
641
+ if (value[field] !== undefined) {
642
+ into.require(isDurationSeconds(value[field]), `task.handlingHistory.${field}`, `${path}.${field}`, `${field} must be a whole number of seconds, zero or more, or omitted when unknown`);
643
+ }
644
+ }
645
+ if (value.transfers !== undefined) {
646
+ into.require(typeof value.transfers === "number" && Number.isInteger(value.transfers) && value.transfers >= 0, "task.handlingHistory.transfers", `${path}.transfers`, "transfers must be a whole number, zero or more, or omitted when unknown");
647
+ }
648
+ if (!Array.isArray(value.steps)) {
649
+ into.add("task.handlingHistory.steps.shape", `${path}.steps`, "handlingHistory carries its steps as an array, empty when the task has had none");
650
+ return;
651
+ }
652
+ let previous;
653
+ value.steps.forEach((entry, index) => {
654
+ const at = `${path}.steps[${index}]`;
637
655
  if (!isPlainObject(entry)) {
638
656
  into.add("task.handlingHistory.entry", at, "each handling step must be an object");
639
657
  return;
640
658
  }
641
659
  into.oneOf(entry.step, HANDLING_STEPS, "task.handlingHistory.step", `${at}.step`);
642
- into.timestamp(entry.at, "task.handlingHistory.at", `${at}.at`);
660
+ if (into.timestamp(entry.at, "task.handlingHistory.at", `${at}.at`)) {
661
+ // The record is one entry per occurrence, oldest first: a second hold is a second entry
662
+ // after the first, never a revision of it or an entry filed out of its turn.
663
+ const instant = Date.parse(entry.at);
664
+ if (previous !== undefined && instant < previous) {
665
+ into.add("task.handlingHistory.order", `${at}.at`, "handling steps are oldest first; this entry is earlier than the one before it");
666
+ }
667
+ previous = instant;
668
+ }
643
669
  if (entry.seconds !== undefined) {
644
670
  // Omitted while a leg is still running. Nought is a claim that it took no time.
645
671
  into.require(isDurationSeconds(entry.seconds) && entry.seconds > 0, "task.handlingHistory.seconds", `${at}.seconds`, "seconds must be a positive whole number; omit it while the step is still running");
@@ -1391,6 +1417,35 @@ export function validateHostGuarantees(guarantees, path = "host.guarantees") {
1391
1417
  }
1392
1418
  return into.violations;
1393
1419
  }
1420
+ /**
1421
+ * What the host reports of a leg it performed, as an adapter may validate it before forwarding:
1422
+ * a task, a step, when it began, how long so far if the host says, and an explicit end that
1423
+ * carries the final duration.
1424
+ */
1425
+ export function validateHandlingReport(report, path = "handlingReport", manifest) {
1426
+ const into = new Collector();
1427
+ if (!isPlainObject(report)) {
1428
+ into.add("handlingReport.shape", path, "a handling report must be an object");
1429
+ return into.violations;
1430
+ }
1431
+ into.require(isTaskId(report.taskId), "handlingReport.taskId", `${path}.taskId`, "a report names the task");
1432
+ into.oneOf(report.step, HANDLING_STEPS, "handlingReport.step", `${path}.step`);
1433
+ into.timestamp(report.at, "handlingReport.at", `${path}.at`);
1434
+ if (report.seconds !== undefined) {
1435
+ into.require(isDurationSeconds(report.seconds) && report.seconds > 0, "handlingReport.seconds", `${path}.seconds`, "seconds must be a positive whole number; omit it rather than report nought");
1436
+ }
1437
+ if (report.ended !== undefined) {
1438
+ if (into.require(report.ended === true, "handlingReport.ended", `${path}.ended`, "ended is declared by presence, as true; a running leg omits it")) {
1439
+ into.require(report.seconds !== undefined, "handlingReport.ended.seconds", `${path}.seconds`, "an ended leg states its final duration");
1440
+ }
1441
+ }
1442
+ else if (report.seconds !== undefined && manifest !== undefined) {
1443
+ // A running report crosses only to a provider that asked for one: begin and end are the whole
1444
+ // of what the rest receive.
1445
+ into.require(isPlainObject(manifest) && manifest.runningStepReports === true, "handlingReport.running.unexpected", `${path}.seconds`, "this provider takes begin and end only; a running report was never asked for");
1446
+ }
1447
+ return into.violations;
1448
+ }
1394
1449
  export function validateHostReport(report, path = "host") {
1395
1450
  const into = new Collector();
1396
1451
  if (!isPlainObject(report)) {
@@ -1456,6 +1511,7 @@ const RESULT_STATUSES = {
1456
1511
  executeTeamConsult: { success: "applied", failure: "failed" },
1457
1512
  openMedia: { success: "opened", failure: "unavailable" },
1458
1513
  setPreference: { success: "applied", failure: "failed" },
1514
+ recordStep: { success: "recorded", failure: "failed" },
1459
1515
  executeTeamPolicy: { success: "applied", failure: "failed" },
1460
1516
  };
1461
1517
  /**
package/guide.md CHANGED
@@ -178,6 +178,7 @@ type Manifest<C extends Channel = Channel> = {
178
178
  phaseLabels?: TaskPhaseLabels;
179
179
  taskTypePresentation?: Record<string, TaskTypePresentation>;
180
180
  orgLevels?: LevelDeclaration[];
181
+ runningStepReports?: true;
181
182
  };
182
183
  ```
183
184
 
@@ -360,6 +361,15 @@ type SetPreferenceRequest =
360
361
  | { id: PreferenceId; enabled: boolean }
361
362
  | { id: PreferenceId; inherit: true };
362
363
 
364
+ type HandlingReport = { taskId: TaskId; step: HandlingStep; at: IsoTimestamp } & (
365
+ | { ended: true; seconds: DurationSeconds }
366
+ | { ended?: never; seconds?: DurationSeconds }
367
+ );
368
+
369
+ type HandlingReportResult =
370
+ | { status: "recorded" }
371
+ | { status: "failed"; failure: ProtocolFailure };
372
+
363
373
  type PreferenceResult =
364
374
  | { status: "applied" }
365
375
  | { status: "failed"; failure: ProtocolFailure };
@@ -549,6 +559,14 @@ type HandlingStep =
549
559
  | "conferenced"
550
560
  | "unanswered";
551
561
 
562
+ type TaskHandlingHistory = {
563
+ steps: TaskHandlingStep[];
564
+ handleSeconds?: DurationSeconds;
565
+ holdSeconds?: DurationSeconds;
566
+ queueSeconds?: DurationSeconds;
567
+ transfers?: number;
568
+ };
569
+
552
570
  type TaskHandlingStep = {
553
571
  step: HandlingStep;
554
572
  at: IsoTimestamp;
@@ -607,7 +625,7 @@ type Task<C extends Channel = Channel> = {
607
625
  acceptance?: AcceptanceMode;
608
626
  reference?: string;
609
627
  attributes?: TaskAttribute[];
610
- handlingHistory?: TaskHandlingStep[];
628
+ handlingHistory?: TaskHandlingHistory;
611
629
  } & TaskCompletion & (
612
630
  C extends "voice"
613
631
  ? { consultation?: TaskConsultation; lead?: TaskLead; assisting?: TaskAssisting; media?: TaskMediaState }
@@ -990,6 +1008,7 @@ type Connection<C extends Channel = Channel> = {
990
1008
  executeTeamConsult?(request: TeamConsultCommandRequest): Promise<TeamCommandResult>;
991
1009
  openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
992
1010
  setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
1011
+ recordStep?(report: HandlingReport): Promise<HandlingReportResult>;
993
1012
  executeTeamPolicy?(request: TeamPolicyCommandRequest): Promise<TeamCommandResult>;
994
1013
  };
995
1014
 
@@ -1382,6 +1401,7 @@ compile time.
1382
1401
  | `phaseLabels` | Optional static adapter-defined display names for canonical `TaskPhase` values. They cannot vary at runtime. |
1383
1402
  | `taskTypePresentation` | Optional static adapter-defined presentation keyed by exact `taskType`. It names the item and its optional agent-facing reference. |
1384
1403
  | `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**. |
1404
+ | `runningStepReports` | The provider takes running reports of a host-performed step — `recordStep` with `seconds` so far and no `ended`. Omitted, the host sends exactly two reports per leg, when it began and when it ended, and a running one is refused. See **The host records what it performs**. |
1385
1405
 
1386
1406
  ### Authentication methods
1387
1407
 
@@ -1886,6 +1906,7 @@ surface in one place, and what obliges an adapter to implement each one.
1886
1906
  | `executeTeamBreak(command)` | The login declares `capabilities.team.breakControl`. |
1887
1907
  | `executeTeamConsult(command)` | The login declares `capabilities.team.consultControl`. |
1888
1908
  | `setPreference(request)` | The login declares `capabilities.preferences`: the person's choice has to have somewhere to go. |
1909
+ | `recordStep(report)` | A task declares `mute`: the host performs that leg, and the provider's record has to have somewhere to take it. See **The host records what it performs**. |
1889
1910
  | `executeTeamPolicy(command)` | The login declares `capabilities.team.policyControl`. |
1890
1911
  | `openMedia(request)` | The manifest channel is `voice`. Every voice task's audio lands in Omni, so there is no voice adapter that does not implement it. |
1891
1912
 
@@ -2090,7 +2111,7 @@ time. Runtime conformance checks also require the task channel to match its prov
2090
2111
  | `completionMode` | `agent-command` waits for the channel's `complete` command; `provider-automatic` completes without one. |
2091
2112
  | `wrapAllowance` | Fixed time allowed to complete the task after primary handling ends. For real-time media, it begins after `task-media-ended`. Required under `provider-automatic`, where the provider acts on it. Optional under `agent-command`: omitted says the provider imposes no deadline, and Omni counts nothing down. |
2092
2113
  | `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. |
2093
- | `handlingHistory` | Optional ordered handling history for this currently open task. It is live task data, not a permanent archive. |
2114
+ | `handlingHistory` | The call record: `steps` — the ordered handling history of this open task, one entry per occurrence, oldest first — and what they add up to before this agent, `handleSeconds`, `holdSeconds`, `queueSeconds`, `transfers`, each present when the provider knows it. Live task data restated with the task, not a permanent archive. See **How a task has been handled**. |
2094
2115
  | `consultation` | Voice only. Present while the agent is consulting a transfer destination: who is being consulted, and since when where the provider records it. Its presence is what makes `transfer` `complete` and `cancel` issuable. `label` is a name for the destination -- a person, a queue -- not a phrase; the host supplies the verb. See **Consult transfer**. |
2095
2116
  | `lead` | Voice only. Present from the agent's request for a lead until the lead leaves or the request ends: `requested` while nobody has joined, `joined` with the lead's `leadId` once somebody has. See **Consulting a lead**. |
2096
2117
  | `assisting` | Voice only, on the lead's own task for a call they joined: which member asked, with their note. Its presence is what makes `lead` `take-over` and `leave` issuable. See **Consulting a lead**. |
@@ -2254,13 +2275,23 @@ const immediateProviderCompletion = {
2254
2275
 
2255
2276
  ### How a task has been handled
2256
2277
 
2257
- `Task.handlingHistory` is the sequence of steps that brought the task to the agent, oldest first:
2278
+ `Task.handlingHistory` is the call record: the steps that brought the task to the agent, oldest
2279
+ first, and what they add up to before this agent:
2258
2280
 
2259
2281
  ```ts
2260
- handlingHistory: [
2261
- { step: "queued", at: "2026-08-21T00:59:00Z", seconds: 41 },
2262
- { step: "answered", at: "2026-08-21T00:59:41Z", by: "a-17" },
2263
- ]
2282
+ handlingHistory: {
2283
+ steps: [
2284
+ { step: "queued", at: "2026-08-21T00:59:00Z", seconds: 41 },
2285
+ { step: "answered", at: "2026-08-21T00:59:41Z", seconds: 312, by: "a-17" },
2286
+ { step: "held", at: "2026-08-21T01:02:10Z", seconds: 35, by: "a-17" },
2287
+ { step: "transferred", at: "2026-08-21T01:04:53Z", by: "a-17" },
2288
+ { step: "answered", at: "2026-08-21T01:05:02Z", by: "a-23" },
2289
+ ],
2290
+ handleSeconds: 312, // handled by others before this agent
2291
+ holdSeconds: 35, // holds others put the caller on
2292
+ queueSeconds: 41, // waiting before anyone answered
2293
+ transfers: 1, // hands the call has changed
2294
+ }
2264
2295
  ```
2265
2296
 
2266
2297
  **This is not an archive.** It is live data about a task that is still open: it travels with the task
@@ -2273,6 +2304,16 @@ It rides in the snapshot and is replaced whole like everything else there.
2273
2304
  Steps are `queued`, `offered`, `answered`, `held`, `muted`, `transferred`, `conferenced`,
2274
2305
  `unanswered`, and each is defined on `TaskHandlingStep`.
2275
2306
 
2307
+ **The record is one entry per occurrence, oldest first.** Each hold is its own `held` entry — `at`
2308
+ when it began, `seconds` once it ended and omitted while it runs — and a second hold is a second
2309
+ entry after the first, never a revision of it. The same goes for every step: two mutes are two
2310
+ `muted` entries. Order is enforced: an entry earlier than the one before it is refused
2311
+ (`task.handlingHistory.order`). **Handle time is anchored, not restarted.** It runs from the
2312
+ `answered` step's `at` — from the task's first `in-progress` where the provider reports no
2313
+ history — until the task's media ends, and a hold neither pauses nor resets it: the hold's own
2314
+ duration is the `held` entry's `seconds`, and a desk that restarts its counter on resume is
2315
+ counting the wrong thing.
2316
+
2276
2317
  `muted` is there because Omni performs the mute rather than the provider — see **Where a command
2277
2318
  executes** — so without a step the one participant that keeps the task's record would have no
2278
2319
  account of a period the agent could not be heard.
@@ -2309,8 +2350,46 @@ A host must render the two differently. Showing an unattributed `answered` the s
2309
2350
  `queued` tells the agent nobody was involved, which is not what was said. Omni renders it as
2310
2351
  *"not recorded"* in the place the name would go.
2311
2352
 
2312
- Omit `handlingHistory` entirely when the provider cannot observe the steps. An empty array is a different
2313
- claim — it says the task has had none.
2353
+ Omit `handlingHistory` entirely when the provider cannot observe the steps. Empty `steps` is a
2354
+ different claim — it says the task has had none.
2355
+
2356
+ **What the record adds up to is stated, not summed.** A call that has changed hands arrives
2357
+ carrying what others already used of it, and the agent reads that before they say hello: the
2358
+ totals are the provider's own — never a desk summing instants that may be rounded or skewed, for
2359
+ the same reason `seconds` is reported and not derived — and each is present when the provider
2360
+ knows it and absent when it does not, never a plausible nought. A fresh call from the queue has
2361
+ `steps` with no prior `answered` and no totals to state, and a desk shows nothing rather than
2362
+ zeros. The record is restated with the task on every `task-updated` and snapshot, so an entry
2363
+ that arrives late corrects the sums. These four are what every platform reports; more will be
2364
+ added here as a need is shown, not invented ahead of one.
2365
+
2366
+ #### The host records what it performs
2367
+
2368
+ `muted` is the one leg Omni performs rather than the provider, and a record kept by the provider
2369
+ would have a hole exactly there. So the host reports it, through `recordStep`, and the provider
2370
+ writes it into its record as it writes every other leg:
2371
+
2372
+ ```ts
2373
+ declare const connection: Connection<"voice">;
2374
+ declare const taskId: TaskId;
2375
+ declare const at: IsoTimestamp;
2376
+
2377
+ void connection.recordStep?.({ taskId, step: "muted", at }); // the moment the agent mutes
2378
+ void connection.recordStep?.({ taskId, step: "muted", at, seconds: 15 }); // still running, if the host chooses to say
2379
+ void connection.recordStep?.({ taskId, step: "muted", at, seconds: 42, ended: true }); // the moment they unmute
2380
+ ```
2381
+
2382
+ The entry is keyed by `step` and `at`, so every report about one leg names the same instant. The
2383
+ host is the authority for the legs it performs, so it may say how long so far — `seconds` is
2384
+ elapsed while the leg runs and final once it has ended — and **the end is stated, never
2385
+ inferred**: `ended: true` marks the last report, and it carries the final duration. **What a
2386
+ provider never asked for never crosses.** The running report is sent only to a provider whose
2387
+ manifest declares `runningStepReports`; every other provider receives exactly two reports per
2388
+ leg, when it began and when it ended, and `validateHandlingReport(report, path, manifest)` refuses
2389
+ a running one it was never asked for (`handlingReport.running.unexpected`). What a provider that
2390
+ did ask for them forwards upstream, and how often, is its own business. The step appears in
2391
+ `handlingHistory` when the *provider* publishes it: Omni never writes the record itself.
2392
+ `recordStep` is required of a connection whose tasks declare `mute`, and answers `recorded`.
2314
2393
 
2315
2394
  ### Browser capability
2316
2395
 
@@ -2706,6 +2785,7 @@ everywhere; success is not.
2706
2785
  | Method | Succeeded |
2707
2786
  | --- | --- |
2708
2787
  | `setCapacity` | `applied` |
2788
+ | `recordStep` | `recorded` |
2709
2789
  | `requestBreak` | `requested` |
2710
2790
  | `commitBreak` | `committed` |
2711
2791
  | `cancelBreak` | `cancelled` |
@@ -3702,6 +3782,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
3702
3782
  | `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
3703
3783
  | `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
3704
3784
  | `validateHostGuarantees(guarantees)` | What a host promises: only the guarantees this contract names, each declared by presence and never `false`. The harness validates the guarantees of whatever host a test hands the adapter. |
3785
+ | `validateHandlingReport(report, path?, manifest?)` | What the host reports of a leg it performed, for an adapter to check before forwarding: a task, a step, when it began, a positive `seconds` where stated, and an explicit `ended` that carries the final duration. Given the manifest, a running report is refused unless it declares `runningStepReports`. |
3705
3786
  | `validateHostReport(report)` | The host's own report as published to an adapter: `online`, and where there is audio, an input that is `available` with the microphone and `flowing`, or `unavailable` with a reason and the failure that says why, and an output that is `available` or `unavailable` with its failure. The harness validates whatever host a test hands the adapter; `stillHost(report)` builds one that never changes. |
3706
3787
  | `validateResult(result, method)` | What a connection method answered: the status it gives, a failure where the status says so and nowhere else, the failure's shape, and that an `omni.` code is one this contract names. |
3707
3788
  | `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. Omni applies it to every state a session publishes — the republished as much as the first. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",