@xema/omni-protocol 0.1.117 → 0.1.119

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
@@ -513,40 +513,61 @@ export interface TaskRecordingPolicy {
513
513
  storageId: string;
514
514
  };
515
515
  }
516
- /** Current evidence only. Expiry/disconnect means unknown, never a historical stop. */
517
- export type RecordingState = {
518
- status: "unknown";
519
- observationId?: never;
520
- observedAt?: never;
521
- validUntil?: never;
522
- recordingId?: never;
523
- } | ({
524
- observationId: string;
525
- observedAt: IsoTimestamp;
526
- validUntil: IsoTimestamp;
516
+ /** Whose side of the call a recording captures: the party's channel, or the agent's own. */
517
+ export type RecordingSide = "party" | "agent";
518
+ export declare const RECORDING_SIDES: readonly ["party", "agent"];
519
+ /**
520
+ * Where a recording stands, as its recorder last stated it. `starting` is asked for and not yet
521
+ * confirmed. A stop or a pause in flight stays `recording` until the recorder confirms it, since
522
+ * telling the agent they are not recorded when they may be is the one thing this must never say.
523
+ */
524
+ export type RecordingStatus = "starting" | "recording" | "paused" | "not-recording" | "unknown";
525
+ export declare const RECORDING_STATUSES: readonly ["starting", "recording", "paused", "not-recording", "unknown"];
526
+ /** Why a recording is paused: someone asked, or the platform paused it while sensitive details are taken. */
527
+ export type RecordingPausedReason = "requested" | "sensitive-details";
528
+ export declare const RECORDING_PAUSED_REASONS: readonly ["requested", "sensitive-details"];
529
+ /** Why a recording's state is unknown: its recorder cannot be reached, or a command could not be settled either way. */
530
+ export type RecordingUnknownReason = "recorder-unreachable" | "unsettled";
531
+ export declare const RECORDING_UNKNOWN_REASONS: readonly ["recorder-unreachable", "unsettled"];
532
+ /**
533
+ * One recording of one side of the call, published whenever it changes and restated whole with
534
+ * every publication of the task and on every snapshot. Nothing expires: the mark stands as last
535
+ * stated until its recorder says otherwise. `id` is the recorder's, minted when the start is
536
+ * accepted -- before the recorder has answered -- and kept through pause and resume; a later
537
+ * start is a new recording with a new id.
538
+ */
539
+ export type Recording = {
540
+ id: string;
541
+ follows: RecordingSide;
527
542
  } & ({
528
- status: "inactive";
529
- recordingId?: never;
543
+ status: "starting" | "recording" | "not-recording";
544
+ reason?: never;
530
545
  } | {
531
- status: "active" | "paused";
532
- recordingId: string;
533
- }));
534
- /** Opaque IDs are scoped by login, task and recorder owner; preserved across pause/resume. */
546
+ status: "paused";
547
+ reason: RecordingPausedReason;
548
+ } | {
549
+ status: "unknown";
550
+ reason: RecordingUnknownReason;
551
+ });
552
+ /**
553
+ * A start names the side to record; every other action names the recording by its id, and the
554
+ * recorder refuses one that no longer stands where the action needs it.
555
+ */
535
556
  export type RecordingCommand = {
536
557
  type: "recording";
537
558
  source: RecordingSource;
538
- /** Compare-and-set against current evidence; never act on a replacement recording. */
539
- observationId: string;
540
559
  } & ({
541
560
  action: "start";
561
+ follows: RecordingSide;
542
562
  recordingId?: never;
543
563
  } | {
544
564
  action: "pause" | "resume" | "stop" | "cancel";
545
565
  recordingId: string;
566
+ follows?: never;
546
567
  });
547
568
  export interface HostRecordingReport {
548
569
  assignmentId: AssignmentId;
549
- state: RecordingState;
570
+ recordings: Recording[];
550
571
  }
551
572
  /** Host declaration in ConnectContext, not the provider-owned Manifest. */
552
573
  export interface HostRecording {
@@ -834,7 +855,7 @@ export type TaskAttribute = TaskAttributeBase & ({
834
855
  type: "timestamp";
835
856
  at: IsoTimestamp;
836
857
  });
837
- export type HistoryStep = "queued" | "offered" | "answered" | "held" | "muted" | "transferred" | "conferenced" | "unanswered";
858
+ export type HistoryStep = "queued" | "offered" | "answered" | "held" | "muted" | "transferred" | "conferenced" | "unanswered" | "callback-requested";
838
859
  /**
839
860
  * The call record: the steps that brought the task here, one entry per occurrence and oldest
840
861
  * first, and what they add up to before this agent -- stated by the provider from its own record,
@@ -869,8 +890,9 @@ export interface TaskHistoryStep {
869
890
  */
870
891
  seconds?: DurationSeconds;
871
892
  /**
872
- * Who took part. Absent on `queued`, where nobody does; absent on any other step means the
873
- * provider could not attribute it, which is a different claim and a legitimate one.
893
+ * Who took part. Absent on `queued`, where nobody does; absent on `callback-requested`, where the
894
+ * caller chose it in the queue, and present there when an agent arranged it; absent on any other
895
+ * step means the provider could not attribute it, which is a different claim and a legitimate one.
874
896
  */
875
897
  by?: UserId;
876
898
  /** On a `muted` step, and only there: whose the silence was, as the host reported it. */
@@ -1064,15 +1086,13 @@ export type Task<C extends Channel = Channel> = {
1064
1086
  attributes?: TaskAttribute[];
1065
1087
  history?: TaskHistory;
1066
1088
  } & TaskCompletion & (C extends "voice" ? {
1067
- recording?: {
1068
- provider?: RecordingState;
1069
- };
1089
+ recordings?: Recording[];
1070
1090
  onCall?: OnCall[];
1071
1091
  leadAssist?: TaskLeadAssist;
1072
1092
  takenOver?: TaskTakenOver;
1073
1093
  audio?: TaskAudioState;
1074
1094
  } : {
1075
- recording?: never;
1095
+ recordings?: never;
1076
1096
  onCall?: never;
1077
1097
  leadAssist?: never;
1078
1098
  takenOver?: never;
@@ -1878,7 +1898,7 @@ export interface ProviderEventEnvelope<C extends Channel = Channel> {
1878
1898
  occurredAt: IsoTimestamp;
1879
1899
  event: ProviderEvent<C>;
1880
1900
  }
1881
- export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.assignment-not-found", "omni.destination-not-permitted", "omni.phone-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed", "omni.recording-unsettled", "omni.break-forced-by-provider"];
1901
+ export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.assignment-not-found", "omni.destination-not-permitted", "omni.phone-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed", "omni.recording-unsettled", "omni.on-hold", "omni.break-forced-by-provider"];
1882
1902
  export type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
1883
1903
  /**
1884
1904
  * What the host refused, told to the adapter that published it: a snapshot the host would not
@@ -2022,7 +2042,10 @@ export declare const assignmentKey: (providerId: string, assignmentId: Assignmen
2022
2042
  * same provider; anything wider goes through this.
2023
2043
  */
2024
2044
  export declare const userKey: (providerId: string, userId: UserId) => string;
2025
- /** Every history step somebody takes part in. `queued` is the one nobody does. */
2045
+ /**
2046
+ * Every history step somebody from the provider takes part in. `queued` is the one nobody does, and
2047
+ * `callback-requested` is the caller's choice unless an agent arranged it, so neither is here.
2048
+ */
2026
2049
  export declare const HISTORY_STEPS_WITH_A_PERSON: readonly ["offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered"];
2027
2050
  /** The steps a dial writes, and so the only ones that carry a `dialId` and a `destinationId`. */
2028
2051
  export declare const HISTORY_STEPS_THAT_DIAL: readonly ["transferred", "conferenced", "unanswered"];
package/dist/index.js CHANGED
@@ -32,6 +32,10 @@ export const LISTENING_MODES = ["listen", "coach", "join-call"];
32
32
  export const MUTED_BY = ["host", "station"];
33
33
  export const HOST_MUTES = ["stream", "station"];
34
34
  export const RECORDING_ACTIONS = ["start", "pause", "resume", "stop", "cancel"];
35
+ export const RECORDING_SIDES = ["party", "agent"];
36
+ export const RECORDING_STATUSES = ["starting", "recording", "paused", "not-recording", "unknown"];
37
+ export const RECORDING_PAUSED_REASONS = ["requested", "sensitive-details"];
38
+ export const RECORDING_UNKNOWN_REASONS = ["recorder-unreachable", "unsettled"];
35
39
  // ---------------------------------------------------------------------------
36
40
  // Task workspace.
37
41
  // ---------------------------------------------------------------------------
@@ -107,6 +111,8 @@ export const OMNI_FAILURE_CODES = [
107
111
  "omni.break-already-committed",
108
112
  /** A recording command the provider could not settle either way: no start, stop, pause or resume it can vouch for. */
109
113
  "omni.recording-unsettled",
114
+ /** Refused because the caller is on hold: the host's Mute, or end-call, waits for resume. */
115
+ "omni.on-hold",
110
116
  /** A lead asked to lift a break the platform itself imposed; the platform lifts its own. */
111
117
  "omni.break-forced-by-provider",
112
118
  ];
@@ -158,7 +164,10 @@ export const assignmentKey = (providerId, assignmentId) => `${encodeURIComponent
158
164
  * same provider; anything wider goes through this.
159
165
  */
160
166
  export const userKey = (providerId, userId) => `${encodeURIComponent(providerId)}:${encodeURIComponent(userId)}`;
161
- /** Every history step somebody takes part in. `queued` is the one nobody does. */
167
+ /**
168
+ * Every history step somebody from the provider takes part in. `queued` is the one nobody does, and
169
+ * `callback-requested` is the caller's choice unless an agent arranged it, so neither is here.
170
+ */
162
171
  export const HISTORY_STEPS_WITH_A_PERSON = [
163
172
  "offered", "answered", "held", "muted", "transferred", "conferenced", "unanswered",
164
173
  ];
package/dist/testing.d.ts CHANGED
@@ -160,8 +160,13 @@ export declare class TaskStream {
160
160
  */
161
161
  private holdCountdowns;
162
162
  private readonly dials;
163
+ private readonly placedHere;
163
164
  /** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
164
165
  dialled(dialId: string): void;
166
+ /** A dial the test placed itself and the provider answered dialling: it is owed exactly one outcome. */
167
+ placedByTest(dialId: string): void;
168
+ /** The dials the test placed that never got their outcome. */
169
+ dialsWithoutOutcome(): string[];
165
170
  private noteDials;
166
171
  /**
167
172
  * Why an event names no open task: the assignment ended, so this is the late event that must not
@@ -210,8 +215,6 @@ export declare class TeamStream {
210
215
  private static ids;
211
216
  /** A snapshot carrying a team is the team whole; one carrying none leaves the lead with none. */
212
217
  seed(snapshot: unknown): void;
213
- /** Whether the team currently carries this member, as the events left it. */
214
- has(id: string): boolean;
215
218
  apply(envelope: unknown, path?: string): ProtocolViolation[];
216
219
  }
217
220
  export declare class BreakStream {
@@ -250,6 +253,15 @@ export declare function stillHost(report?: HostReport, guarantees?: HostGuarante
250
253
  mute?: never;
251
254
  recording?: never;
252
255
  };
256
+ /**
257
+ * One call's history as it reaches a second task: an earlier agent's task and a later one of the same
258
+ * call -- a hand-over, a take-over, a callback. The later task restates every earlier step, in order,
259
+ * as the earlier task had it: the same step, instant, person, dial and destination, and the same
260
+ * duration wherever the earlier task had already stated one (a leg still open there may be closed
261
+ * here). Throws on the first step that disagrees, and when either task carries no history, since
262
+ * two absent records agree about nothing.
263
+ */
264
+ export declare function assertHistoryAgreesAcrossTasks(earlier: Task, later: Task): void;
253
265
  /** One provider as the host sees it when freezing the providers a break attempt asks. */
254
266
  export interface BreakCandidate {
255
267
  id: string;
package/dist/testing.js CHANGED
@@ -689,6 +689,12 @@ export async function testAdapter(adapter, context, options = {}) {
689
689
  violations.push({ rule: "stream.taskOffered.unended", path: "event.task-ended",
690
690
  message: `${id} was offered and never ended: every offer is owed a task-ended, whatever became of the call` });
691
691
  }
692
+ // Every dial the test placed, and the provider answered dialling, is owed exactly one outcome.
693
+ ruleTested("stream.dialOutcome.missing");
694
+ for (const id of stream.dialsWithoutOutcome()) {
695
+ violations.push({ rule: "stream.dialOutcome.missing", path: "event.dial-outcome",
696
+ message: `${id} was answered dialling and never got its dial-outcome: every dial ends in exactly one, however it ended` });
697
+ }
692
698
  }
693
699
  // Capacity supersedes rather than accumulates, so a decrease is as ordinary as an increase: the
694
700
  // host raises it, lowers it, and takes it away, and the provider takes each as the ceiling it is.
@@ -915,6 +921,10 @@ export function assertAuthenticationRestoreAndExpiry(states) {
915
921
  }
916
922
  }
917
923
  /** The tasks an envelope carries: the one a task event names, or a snapshot event's list. */
924
+ /** The failure code of a failed answer, as the provider stated it. */
925
+ function failureCode(answer) {
926
+ return isRecord(answer.failure) ? answer.failure.code : undefined;
927
+ }
918
928
  /** Whether a published state ends the session, so its stored secrets must already be gone. */
919
929
  function sessionEnded(state) {
920
930
  return state.status === "signed-out" || state.status === "terminated"
@@ -1189,11 +1199,19 @@ export class TaskStream {
1189
1199
  // task carried on `onCall` or in its record -- which is how a dial made before a take-over is known
1190
1200
  // to whoever holds the task now. `answered` or `ended` once its outcome arrived, since it comes once.
1191
1201
  dials = new Map();
1202
+ placedHere = new Set();
1192
1203
  /** The host placed a dial: its outcome, whenever it arrives, is one the stream expects. */
1193
1204
  dialled(dialId) {
1194
1205
  if (!this.dials.has(dialId))
1195
1206
  this.dials.set(dialId, "placed");
1196
1207
  }
1208
+ /** A dial the test placed itself and the provider answered dialling: it is owed exactly one outcome. */
1209
+ placedByTest(dialId) {
1210
+ this.dialled(dialId);
1211
+ this.placedHere.add(dialId);
1212
+ }
1213
+ /** The dials the test placed that never got their outcome. */
1214
+ dialsWithoutOutcome() { return [...this.placedHere].filter(id => this.dials.get(id) === "placed"); }
1197
1215
  noteDials(task) {
1198
1216
  if (!isRecord(task))
1199
1217
  return;
@@ -1856,6 +1874,7 @@ async function testOneCall(call) {
1856
1874
  call.stream.dialled(dialId);
1857
1875
  if (await send({ type: "dial", dialId }, dialId) === undefined)
1858
1876
  return found;
1877
+ call.stream.placedByTest(dialId);
1859
1878
  }
1860
1879
  if (latestTask().phase === "preview" && await updated(t => t.phase === "in-progress" || t.phase === "completing", pressCall ? "the task leaving preview after Call" : "the task leaving preview after the provider's dial") === undefined)
1861
1880
  return found;
@@ -1992,6 +2011,9 @@ async function testOneCall(call) {
1992
2011
  if (isRecord(answer) && answer.status !== "failed") {
1993
2012
  refuse("test.recordStep.held", "test.recordStep", "the provider recorded the host's mute begun with the caller on hold: the Mute waits for resume, and the adapter is the second gate");
1994
2013
  }
2014
+ else if (isRecord(answer) && failureCode(answer) !== "omni.on-hold") {
2015
+ refuse("test.recordStep.held", "test.recordStep", `the provider refused the mute begun on hold as ${String(failureCode(answer))}: a refusal because the caller is on hold answers omni.on-hold, so the agent application can say resume first`);
2016
+ }
1995
2017
  };
1996
2018
  // Ending my part from hold would leave the caller in the hold with nobody coming back to them: a
1997
2019
  // host holds it back (command.endCall.held), and an adapter that receives it anyway must refuse
@@ -2011,6 +2033,9 @@ async function testOneCall(call) {
2011
2033
  // Applied, the task leaves paused; the run reads where the adapter put it rather than resuming a call it ended.
2012
2034
  await updated(t => t.phase !== "paused", "the task after an end-call applied from hold");
2013
2035
  }
2036
+ else if (isRecord(answer) && failureCode(answer) !== "omni.on-hold") {
2037
+ refuse("test.command.held", "test.command.end-call", `the provider refused end-call on hold as ${String(failureCode(answer))}: a refusal because the caller is on hold answers omni.on-hold, so the agent application can say resume first`);
2038
+ }
2014
2039
  };
2015
2040
  // 4. Hold and resume, where offered.
2016
2041
  if (latestTask().phase === "in-progress" && offers("hold")) {
@@ -2210,8 +2235,6 @@ export class TeamStream {
2210
2235
  seed(snapshot) {
2211
2236
  this.members = TeamStream.ids(isRecord(snapshot) ? snapshot.team : undefined);
2212
2237
  }
2213
- /** Whether the team currently carries this member, as the events left it. */
2214
- has(id) { return this.members?.has(id) === true; }
2215
2238
  apply(envelope, path = "event") {
2216
2239
  const event = isRecord(envelope) ? envelope.event : undefined;
2217
2240
  if (!isRecord(event))
@@ -2343,6 +2366,39 @@ export function memoryStore() {
2343
2366
  export function stillHost(report = { online: true }, guarantees = {}, mute) {
2344
2367
  return { guarantees, ...(mute === undefined ? {} : { mute }), report: () => report, subscribe: () => () => undefined };
2345
2368
  }
2369
+ /**
2370
+ * One call's history as it reaches a second task: an earlier agent's task and a later one of the same
2371
+ * call -- a hand-over, a take-over, a callback. The later task restates every earlier step, in order,
2372
+ * as the earlier task had it: the same step, instant, person, dial and destination, and the same
2373
+ * duration wherever the earlier task had already stated one (a leg still open there may be closed
2374
+ * here). Throws on the first step that disagrees, and when either task carries no history, since
2375
+ * two absent records agree about nothing.
2376
+ */
2377
+ export function assertHistoryAgreesAcrossTasks(earlier, later) {
2378
+ const steps = (task, which) => {
2379
+ const history = task.history;
2380
+ if (history === undefined || !Array.isArray(history.steps)) {
2381
+ throw new Error(`The ${which} task ${task.assignmentId} carries no history to compare`);
2382
+ }
2383
+ return history.steps;
2384
+ };
2385
+ const before = steps(earlier, "earlier");
2386
+ const after = steps(later, "later");
2387
+ if (after.length < before.length) {
2388
+ throw new Error(`${later.assignmentId} restates ${after.length} step(s) of the call and ${earlier.assignmentId} already had ${before.length}: a later task carries every earlier step`);
2389
+ }
2390
+ before.forEach((step, index) => {
2391
+ const restated = after[index];
2392
+ for (const field of ["step", "at", "by", "dialId", "destinationId", "mutedBy"]) {
2393
+ if (step[field] !== restated[field]) {
2394
+ throw new Error(`steps[${index}].${field} is ${String(step[field])} on ${earlier.assignmentId} and ${String(restated[field])} on ${later.assignmentId}: one call has one history`);
2395
+ }
2396
+ }
2397
+ if (step.seconds !== undefined && restated.seconds !== step.seconds) {
2398
+ throw new Error(`steps[${index}].seconds is ${step.seconds} on ${earlier.assignmentId} and ${String(restated.seconds)} on ${later.assignmentId}: a stated duration does not change`);
2399
+ }
2400
+ });
2401
+ }
2346
2402
  const usableLogin = (status) => status === "authenticated" || status === "refreshing";
2347
2403
  /**
2348
2404
  * A break one provider forces stops the agent everywhere: the host states a capacity of zero on
@@ -1,4 +1,4 @@
1
- import { type RecordingState, type Channel, type Phone, type ProtocolViolation, type UserCapabilities, type UserId } from "./index.js";
1
+ import { type Channel, type Phone, type ProtocolViolation, type UserCapabilities, type UserId } from "./index.js";
2
2
  export type { ProtocolViolation } from "./index.js";
3
3
  export declare class ProtocolConformanceError extends Error {
4
4
  readonly violations: readonly ProtocolViolation[];
@@ -198,24 +198,34 @@ export declare function validateAuthenticationFailure(failure: unknown, path?: s
198
198
  */
199
199
  export declare function validateAuthenticationResult(result: unknown, method: "start" | "complete", path?: string, context?: LoginValidationContext): ProtocolViolation[];
200
200
  export declare function validateAuthenticationState(state: unknown, path?: string, context?: LoginValidationContext): ProtocolViolation[];
201
- export declare function validateRecordingState(value: unknown, path?: string): ProtocolViolation[];
202
- /** Supply a trusted current instant in the observer's clock domain. Undefined means clock unknown. */
203
- export declare function effectiveRecordingState(state: RecordingState | undefined, now: number | undefined): RecordingState;
201
+ /**
202
+ * One recorder's recordings for one task, as published: each with its id, the side it follows,
203
+ * its status, and a reason where it is paused or unknown; ids unique, and at most one recording
204
+ * live on each side at once.
205
+ */
206
+ export declare function validateRecordings(value: unknown, path?: string): ProtocolViolation[];
204
207
  export declare function validateRecordingPolicy(value: unknown, path?: string): ProtocolViolation[];
205
208
  export declare function validateHostRecording(value: unknown, softphone: boolean, path?: string): ProtocolViolation[];
206
209
  export declare function validateHostRecordings(value: unknown, path?: string): ProtocolViolation[];
207
210
  export declare function validateRecordingCommandShape(value: unknown, source: "provider" | "host", path?: string): ProtocolViolation[];
208
- /** Structural/state permission check. Dispatch MUST also call validateRecordingRequest for scope and freshness. */
209
- export declare function validateRecordingCommandState(command: unknown, policy: unknown, state: unknown, path?: string): ProtocolViolation[];
210
- /** Validate immediately before dispatch using current task, host report and trusted observer-domain time. */
211
+ /**
212
+ * Whether the action is permitted on this recorder, and whether the recordings as last published
213
+ * let it act: a start on a side with nothing capturing, every other action on the recording it
214
+ * names, standing where the action needs it. Dispatch MUST also call validateRecordingRequest.
215
+ */
216
+ export declare function validateRecordingCommandState(command: unknown, policy: unknown, recordings: unknown, path?: string): ProtocolViolation[];
217
+ /** Validate immediately before dispatch, against the current task and, for the host's recorder, its declaration and report. */
211
218
  export declare function validateRecordingRequest(request: unknown, task: unknown, context: {
212
219
  source: "provider" | "host";
213
- now: number | undefined;
214
220
  host?: unknown;
215
221
  hostReport?: unknown;
216
222
  softphone?: boolean;
217
223
  /** The same provider/task context used to validate the published task. */
218
224
  taskContext?: Omit<TaskValidationContext, "channel">;
219
225
  }, path?: string): ProtocolViolation[];
220
- /** Check recording-specific result semantics against the owner's confirming observations. */
226
+ /**
227
+ * A recording command's answer against the recordings published before and after it: applied
228
+ * means the recorder confirmed what was asked -- a new recording on the side for a start, the
229
+ * same recording in its new status for anything else -- and failed means nothing changed there.
230
+ */
221
231
  export declare function validateRecordingOutcome(command: unknown, before: unknown, after: unknown, result: unknown, path?: string): ProtocolViolation[];
@@ -9,7 +9,7 @@
9
9
  // Each list is pinned to its type both ways -- a member the type lacks, or a member the list
10
10
  // lacks, fails to compile -- so what the validators accept cannot drift from what the
11
11
  // declarations say.
12
- import { RECORDING_ACTIONS, SHIFT_EVENT_KINDS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HISTORY_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
12
+ import { RECORDING_ACTIONS, SHIFT_EVENT_KINDS, RECORDING_SIDES, RECORDING_STATUSES, RECORDING_PAUSED_REASONS, RECORDING_UNKNOWN_REASONS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HISTORY_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
13
13
  export class ProtocolConformanceError extends Error {
14
14
  violations;
15
15
  constructor(violations, summary = "Adapter violates the Omni protocol") {
@@ -59,6 +59,7 @@ const TEAM_AVAILABILITIES = membersOf({
59
59
  });
60
60
  const HISTORY_STEPS = membersOf({
61
61
  queued: true, offered: true, answered: true, held: true, muted: true, transferred: true, conferenced: true, unanswered: true,
62
+ "callback-requested": true,
62
63
  });
63
64
  const CUSTOM_UI_CONTROLS = membersOf({ button: true, toggle: true, "menu-item": true });
64
65
  const CUSTOM_UI_PLACEMENTS = membersOf({ primary: true, secondary: true, overflow: true });
@@ -1214,16 +1215,11 @@ function validateTaskInto(task, context, path, into) {
1214
1215
  if (context.locked !== undefined && isPlainObject(task.party) && (isLocked(task.party.number) || isLocked(task.party.email))) {
1215
1216
  validateNothingLeaksInto(task, context.locked, path, into);
1216
1217
  }
1217
- if (task.recording !== undefined) {
1218
- into.require(context.channel === "voice", "recording.task.channel", `${path}.recording`, "recording is voice-only");
1219
- if (!isPlainObject(task.recording))
1220
- into.add("recording.task.shape", `${path}.recording`, "expected provider recording state");
1221
- else {
1222
- for (const key of Object.keys(task.recording))
1223
- into.require(key === "provider", "recording.task.owner", `${path}.recording.${key}`, "host state belongs in HostReport, never provider task updates");
1224
- if (task.recording.provider !== undefined)
1225
- into.violations.push(...validateRecordingState(task.recording.provider, `${path}.recording.provider`));
1226
- }
1218
+ // The provider's own recordings, a list; the host's are on its report, never on a provider's task.
1219
+ into.require(task.recording === undefined, "recording.task.renamed", `${path}.recording`, "a task carries the provider's recordings as recordings, a list with the side each follows; the former recording state is not accepted");
1220
+ if (task.recordings !== undefined) {
1221
+ into.require(context.channel === "voice", "recording.task.channel", `${path}.recordings`, "recording is voice-only");
1222
+ into.violations.push(...validateRecordings(task.recordings, `${path}.recordings`));
1227
1223
  }
1228
1224
  if (context.member !== true)
1229
1225
  validateBrowsers(task.browsers, `${path}.browsers`, into);
@@ -2786,7 +2782,7 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2786
2782
  if (!offered("recording"))
2787
2783
  break;
2788
2784
  const policy = isPlainObject(capabilities.recording) ? capabilities.recording.provider : undefined;
2789
- const state = isPlainObject(task.recording) ? task.recording.provider : undefined;
2785
+ const state = task.recordings ?? [];
2790
2786
  into.violations.push(...validateRecordingCommandState(command, policy, state, path));
2791
2787
  if (command.action === "start" || command.action === "resume") {
2792
2788
  interaction();
@@ -3163,7 +3159,7 @@ export function validateAuthenticationState(state, path = "authentication", cont
3163
3159
  }
3164
3160
  return into.violations;
3165
3161
  }
3166
- // Independent current recording evidence and dispatch validation.
3162
+ // Recordings: published on change, restated whole, never expiring; and dispatch validation.
3167
3163
  const object = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3168
3164
  const filled = (v) => typeof v === "string" && v.trim().length > 0;
3169
3165
  function collector() {
@@ -3180,40 +3176,49 @@ function collector() {
3180
3176
  };
3181
3177
  return { violations, check, keys };
3182
3178
  }
3183
- /** Recording observations use canonical UTC millisecond instants, never native capture boundaries. */
3184
- function instant(v) {
3185
- if (typeof v !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(v))
3186
- return false;
3187
- const n = Date.parse(v);
3188
- return Number.isFinite(n) && new Date(n).toISOString() === v;
3189
- }
3190
- export function validateRecordingState(value, path = "recording") {
3179
+ /** A recording that is, or may be, capturing: its side has no room for a second start. */
3180
+ const LIVE_RECORDING = ["starting", "recording", "paused", "unknown"];
3181
+ /**
3182
+ * One recorder's recordings for one task, as published: each with its id, the side it follows,
3183
+ * its status, and a reason where it is paused or unknown; ids unique, and at most one recording
3184
+ * live on each side at once.
3185
+ */
3186
+ export function validateRecordings(value, path = "recordings") {
3191
3187
  const { violations, check, keys } = collector();
3192
- if (!object(value)) {
3193
- check(false, "state.shape", path, "expected recording state");
3194
- return violations;
3195
- }
3196
- if (value.status === "unknown") {
3197
- keys(value, ["status"], path);
3188
+ if (!Array.isArray(value)) {
3189
+ check(false, "list.shape", path, "recordings are a list, empty when nothing has been recorded");
3198
3190
  return violations;
3199
3191
  }
3200
- check(["inactive", "active", "paused"].includes(value.status), "state.status", `${path}.status`, "expected unknown, inactive, active or paused");
3201
- keys(value, ["status", "observationId", "observedAt", "validUntil", ...(value.status === "inactive" ? [] : ["recordingId"])], path);
3202
- check(filled(value.observationId), "observationId", `${path}.observationId`, "a confirming observation has an opaque identity");
3203
- check(instant(value.observedAt), "observedAt", `${path}.observedAt`, "expected canonical UTC millisecond observation instant");
3204
- check(instant(value.validUntil), "validUntil", `${path}.validUntil`, "expected canonical UTC millisecond expiry instant");
3205
- if (instant(value.observedAt) && instant(value.validUntil))
3206
- check(value.observedAt < value.validUntil, "expiry", path, "expiry must be strictly after observation");
3207
- if (value.status !== "inactive")
3208
- check(filled(value.recordingId), "recordingId", `${path}.recordingId`, "active and paused states identify the actual recording");
3192
+ const ids = new Set();
3193
+ const live = new Set();
3194
+ value.forEach((entry, index) => {
3195
+ const at = `${path}[${index}]`;
3196
+ if (!object(entry)) {
3197
+ check(false, "shape", at, "a recording is an object");
3198
+ return;
3199
+ }
3200
+ keys(entry, ["id", "follows", "status", "reason"], at);
3201
+ check(filled(entry.id), "id", `${at}.id`, "a recording names itself by its recorder's id");
3202
+ check(!ids.has(entry.id), "id.unique", `${at}.id`, "two recordings share an id");
3203
+ ids.add(entry.id);
3204
+ check(RECORDING_SIDES.includes(entry.follows), "follows", `${at}.follows`, "a recording follows the party's side or the agent's");
3205
+ if (!check(RECORDING_STATUSES.includes(entry.status), "status", `${at}.status`, "a recording is starting, recording, paused, not-recording or unknown"))
3206
+ return;
3207
+ const reasons = entry.status === "paused" ? RECORDING_PAUSED_REASONS
3208
+ : entry.status === "unknown" ? RECORDING_UNKNOWN_REASONS : undefined;
3209
+ if (reasons !== undefined) {
3210
+ check(reasons.includes(entry.reason), "reason", `${at}.reason`, `a ${String(entry.status)} recording says why: ${reasons.join(", ")}`);
3211
+ }
3212
+ else {
3213
+ check(entry.reason === undefined, "reason.unexpected", `${at}.reason`, "only a paused or unknown recording carries a reason");
3214
+ }
3215
+ if (LIVE_RECORDING.includes(entry.status)) {
3216
+ check(!live.has(entry.follows), "follows.live", `${at}.follows`, `the ${String(entry.follows)} side already has a recording that is, or may be, capturing`);
3217
+ live.add(entry.follows);
3218
+ }
3219
+ });
3209
3220
  return violations;
3210
3221
  }
3211
- /** Supply a trusted current instant in the observer's clock domain. Undefined means clock unknown. */
3212
- export function effectiveRecordingState(state, now) {
3213
- if (state === undefined || validateRecordingState(state).length || state.status === "unknown" || now === undefined || !Number.isFinite(now))
3214
- return { status: "unknown" };
3215
- return Date.parse(state.observedAt) <= now && now < Date.parse(state.validUntil) ? state : { status: "unknown" };
3216
- }
3217
3222
  export function validateRecordingPolicy(value, path = "capabilities.recording") {
3218
3223
  const { violations, check, keys } = collector();
3219
3224
  if (!object(value)) {
@@ -3286,12 +3291,12 @@ export function validateHostRecordings(value, path = "host.recordings") {
3286
3291
  check(false, "report.shape", at, "expected scoped report");
3287
3292
  return;
3288
3293
  }
3289
- keys(report, ["assignmentId", "state"], at);
3294
+ keys(report, ["assignmentId", "recordings"], at);
3290
3295
  check(filled(report.assignmentId), "report.scope", at, "report identifies the assignment");
3291
3296
  const key = JSON.stringify([report.assignmentId]);
3292
- check(!seen.has(key), "report.duplicate", at, "one host state per assignment");
3297
+ check(!seen.has(key), "report.duplicate", at, "one host report per assignment");
3293
3298
  seen.add(key);
3294
- violations.push(...validateRecordingState(report.state, `${at}.state`));
3299
+ violations.push(...validateRecordings(report.recordings, `${at}.recordings`));
3295
3300
  });
3296
3301
  return violations;
3297
3302
  }
@@ -3301,50 +3306,62 @@ export function validateRecordingCommandShape(value, source, path = "command") {
3301
3306
  check(false, "command.shape", path, "expected recording command");
3302
3307
  return violations;
3303
3308
  }
3304
- keys(value, ["type", "source", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])], path);
3309
+ const starting = value.action === "start";
3310
+ keys(value, ["type", "source", "action", starting ? "follows" : "recordingId"], path);
3305
3311
  check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
3306
3312
  check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
3307
- for (const key of ["observationId", ...(value.action === "start" ? [] : ["recordingId"])])
3308
- check(filled(value[key]), "command.identity", `${path}.${key}`, "expected nonempty scoped identity");
3313
+ if (starting)
3314
+ check(RECORDING_SIDES.includes(value.follows), "command.follows", `${path}.follows`, "a start names the side to record: party or agent");
3315
+ else
3316
+ check(filled(value.recordingId), "command.identity", `${path}.recordingId`, "every action but start names the recording by its id");
3309
3317
  return violations;
3310
3318
  }
3311
3319
  /** One transition table shared by dispatch checks and outcome checks. */
3312
3320
  const RECORDING_TRANSITIONS = {
3313
- start: { from: ["inactive"], to: "active" },
3314
- pause: { from: ["active"], to: "paused" },
3315
- resume: { from: ["paused"], to: "active" },
3316
- stop: { from: ["active", "paused"], to: "inactive" },
3317
- cancel: { from: ["active", "paused"], to: "inactive" },
3321
+ pause: { from: ["recording"], to: "paused" },
3322
+ resume: { from: ["paused"], to: "recording" },
3323
+ stop: { from: ["recording", "paused"], to: "not-recording" },
3324
+ cancel: { from: ["recording", "paused"], to: "not-recording" },
3318
3325
  };
3319
3326
  function recordingTransition(action) {
3320
3327
  return typeof action === "string" && Object.hasOwn(RECORDING_TRANSITIONS, action)
3321
3328
  ? RECORDING_TRANSITIONS[action] : undefined;
3322
3329
  }
3323
- /** Structural/state permission check. Dispatch MUST also call validateRecordingRequest for scope and freshness. */
3324
- export function validateRecordingCommandState(command, policy, state, path = "command") {
3330
+ const entries = (value) => Array.isArray(value) ? value.filter(object) : [];
3331
+ /**
3332
+ * Whether the action is permitted on this recorder, and whether the recordings as last published
3333
+ * let it act: a start on a side with nothing capturing, every other action on the recording it
3334
+ * names, standing where the action needs it. Dispatch MUST also call validateRecordingRequest.
3335
+ */
3336
+ export function validateRecordingCommandState(command, policy, recordings, path = "command") {
3325
3337
  const { violations, check } = collector();
3326
3338
  if (!object(command)) {
3327
3339
  check(false, "command.shape", path, "expected recording command");
3328
3340
  return violations;
3329
3341
  }
3330
- const transition = recordingTransition(command.action);
3331
- if (transition === undefined) {
3342
+ if (!RECORDING_ACTIONS.includes(command.action)) {
3332
3343
  check(false, "command.action", `${path}.action`, "unsupported recording action");
3333
3344
  return violations;
3334
3345
  }
3335
3346
  const permitted = object(policy) && policy[command.action] === true;
3336
3347
  check(permitted, "command.permission", path, "task does not permit this action on this recorder");
3337
- if (!object(state) || validateRecordingState(state).length) {
3338
- check(false, "command.state", path, "no validated current recorder observation");
3348
+ if (recordings !== undefined && validateRecordings(recordings).length) {
3349
+ check(false, "command.state", path, "the recordings as published are malformed");
3350
+ return violations;
3351
+ }
3352
+ const list = entries(recordings);
3353
+ if (command.action === "start") {
3354
+ check(!list.some(entry => entry.follows === command.follows && LIVE_RECORDING.includes(entry.status)), "command.state", path, `the ${String(command.follows)} side already has a recording that is, or may be, capturing`);
3339
3355
  return violations;
3340
3356
  }
3341
- check(transition.from.includes(state.status), "command.state", path, "action is invalid in the observed recorder state");
3342
- check(command.observationId === state.observationId, "command.stale", path, "observation changed; reconcile before acting");
3343
- if (command.action !== "start")
3344
- check(command.recordingId === state.recordingId, "command.recordingId", path, "command targets another recording");
3357
+ const target = list.find(entry => entry.id === command.recordingId);
3358
+ if (!check(target !== undefined, "command.recordingId", path, "no recording on this task has that id"))
3359
+ return violations;
3360
+ const transition = recordingTransition(command.action);
3361
+ check(transition.from.includes(target.status), "command.state", path, `${String(command.action)} needs the recording ${transition.from.join(" or ")}, and it is ${String(target.status)}`);
3345
3362
  return violations;
3346
3363
  }
3347
- /** Validate immediately before dispatch using current task, host report and trusted observer-domain time. */
3364
+ /** Validate immediately before dispatch, against the current task and, for the host's recorder, its declaration and report. */
3348
3365
  export function validateRecordingRequest(request, task, context, path = "request") {
3349
3366
  const { violations, check, keys } = collector();
3350
3367
  if (!object(request) || !object(task)) {
@@ -3362,58 +3379,69 @@ export function validateRecordingRequest(request, task, context, path = "request
3362
3379
  const caps = object(task.capabilities) ? task.capabilities.recording : undefined;
3363
3380
  violations.push(...validateRecordingPolicy(caps)); // a locked or missing capability cannot dispatch
3364
3381
  const policy = object(caps) ? caps[context.source] : undefined;
3365
- let state = object(task.recording) ? task.recording.provider : undefined;
3382
+ let recordings = task.recordings ?? [];
3366
3383
  if (context.source === "host") {
3367
3384
  violations.push(...validateHostRecording(context.host, context.softphone === true));
3368
3385
  check(object(context.host), "host.required", path, "host did not declare recording support");
3369
3386
  const reports = object(context.hostReport) ? context.hostReport.recordings : undefined;
3370
3387
  violations.push(...validateHostRecordings(reports));
3371
- state = Array.isArray(reports) ? reports.find(r => object(r) && r.assignmentId === task.assignmentId)?.state : undefined;
3388
+ const report = Array.isArray(reports) ? reports.find(r => object(r) && r.assignmentId === task.assignmentId) : undefined;
3389
+ recordings = object(report) ? report.recordings : [];
3372
3390
  if (object(context.host)) {
3373
3391
  check(Array.isArray(context.host.actions) && context.host.actions.includes(command.action), "host.action", path, "host does not support action");
3374
3392
  check(object(policy) && Array.isArray(context.host.storageIds) && context.host.storageIds.includes(policy.storageId), "host.storage", path, "task storage is not one this host provisions");
3375
3393
  }
3376
3394
  }
3377
- const fresh = effectiveRecordingState(state, context.now);
3378
- check(fresh.status !== "unknown", "request.freshness", path, "current state or trusted observer time unavailable/expired");
3379
- violations.push(...validateRecordingCommandState(command, policy, fresh, `${path}.command`));
3395
+ violations.push(...validateRecordingCommandState(command, policy, recordings, `${path}.command`));
3380
3396
  const opening = command.action === "start" || command.action === "resume";
3381
3397
  check(opening ? ["in-progress", "paused"].includes(task.phase) && task.audio === "started" : ["in-progress", "paused", "completing"].includes(task.phase), "request.phase", path, "capture needs live task audio; terminal controls may finish recordings during wrap-up");
3382
3398
  return violations;
3383
3399
  }
3384
- /** Check recording-specific result semantics against the owner's confirming observations. */
3400
+ /**
3401
+ * A recording command's answer against the recordings published before and after it: applied
3402
+ * means the recorder confirmed what was asked -- a new recording on the side for a start, the
3403
+ * same recording in its new status for anything else -- and failed means nothing changed there.
3404
+ */
3385
3405
  export function validateRecordingOutcome(command, before, after, result, path = "recordingOutcome") {
3386
3406
  const { violations, check } = collector();
3387
- violations.push(...validateRecordingState(before, `${path}.before`), ...validateRecordingState(after, `${path}.after`));
3388
- if (!object(command) || !object(before) || !object(after)) {
3389
- check(false, "outcome.shape", path, "command and both observations required");
3407
+ violations.push(...validateRecordings(before, `${path}.before`), ...validateRecordings(after, `${path}.after`));
3408
+ if (!object(command) || !Array.isArray(before) || !Array.isArray(after)) {
3409
+ check(false, "outcome.shape", path, "command and both lists of recordings required");
3390
3410
  return violations;
3391
3411
  }
3392
3412
  violations.push(...validateRecordingCommandShape(command, command.source === "host" ? "host" : "provider", `${path}.command`));
3393
- // A rejected promise has no result: any independently evidenced state may follow it.
3413
+ // A rejected promise has no result: any state its recorder publishes may follow it.
3394
3414
  if (result === undefined)
3395
3415
  return violations;
3396
3416
  violations.push(...validateResult(result, "execute", `${path}.result`));
3397
3417
  if (!object(result))
3398
3418
  return violations;
3399
3419
  check(result.status === "applied" || result.status === "failed", "outcome.result", path, "recording never returns a dialling result");
3420
+ const prior = entries(before);
3421
+ const next = entries(after);
3422
+ if (command.action === "start") {
3423
+ const fresh = next.filter(entry => entry.follows === command.follows && !prior.some(old => old.id === entry.id));
3424
+ if (result.status === "applied") {
3425
+ check(fresh.some(entry => entry.status === "recording"), "outcome.state", path, "an applied start shows a new recording on that side, recording");
3426
+ check(!prior.some(entry => entry.follows === command.follows && LIVE_RECORDING.includes(entry.status)), "outcome.origin", path, "applied cannot claim a start on a side already capturing");
3427
+ }
3428
+ else if (result.status === "failed") {
3429
+ check(!fresh.some(entry => LIVE_RECORDING.includes(entry.status)), "outcome.noEffect", path, "a failed start leaves nothing capturing on that side");
3430
+ }
3431
+ return violations;
3432
+ }
3433
+ const was = prior.find(entry => entry.id === command.recordingId);
3434
+ const now = next.find(entry => entry.id === command.recordingId);
3400
3435
  if (result.status === "failed") {
3401
- check(before.status !== "unknown" && after.status === before.status && after.recordingId === before.recordingId, "outcome.noEffect", path, "failed promises confirmed no effect; ambiguous or partial effects have no result");
3436
+ check(was !== undefined && now !== undefined && now.status === was.status, "outcome.noEffect", path, "failed confirms no effect: the recording stands as it was; a partial effect is unknown, unsettled");
3402
3437
  }
3403
3438
  else if (result.status === "applied") {
3404
3439
  const transition = recordingTransition(command.action);
3405
3440
  if (transition === undefined)
3406
3441
  return violations; // shape validation already reported the unknown action
3407
- check(after.status === transition.to, "outcome.state", path, "applied requires the confirming requested state");
3408
- check(after.observationId !== before.observationId, "outcome.observation", path, "an applied transition needs new confirming evidence");
3409
- check(before.observationId === command.observationId, "outcome.stale", path, "command must act against its named observation");
3410
- check(transition.from.includes(before.status), "outcome.origin", path, "applied cannot claim an impossible transition");
3411
- if (command.action !== "start")
3412
- check(command.recordingId === before.recordingId, "outcome.recordingId", path, "command must target the prior recording");
3413
- if (command.action === "pause" || command.action === "resume")
3414
- check(after.recordingId === before.recordingId, "outcome.continuity", path, "pause/resume preserves recording identity");
3415
- if (instant(before.observedAt) && instant(after.observedAt))
3416
- check(after.observedAt >= before.observedAt, "outcome.order", path, "clock discontinuity requires reconciliation, not a reversed confirmation");
3442
+ check(now !== undefined, "outcome.continuity", path, "the recording keeps its id through the action");
3443
+ check(now?.status === transition.to, "outcome.state", path, `applied requires the recording ${transition.to}`);
3444
+ check(was !== undefined && transition.from.includes(was.status), "outcome.origin", path, "applied cannot claim an impossible transition");
3417
3445
  }
3418
3446
  return violations;
3419
3447
  }
package/guide/agent.md CHANGED
@@ -384,6 +384,9 @@ Migration from the earlier spellings:
384
384
  | the phone unspoken | `PhoneState` -- the device, its mute, its channels -- on `Snapshot.phone`, `phone-updated` and `TeamMember.phone`, from a manifest declaring `phoneStatus` (`snapshot.phone.required`, `.unexpected`, `phone.*`, `phone.channel.*`, `event.phone.capability`) |
385
385
  | the agent's readiness for the next call read into setCapacity | the agent's own queue: `capabilities.nextCall`, `requestNextCall`/`cancelNextCall`/`releaseLinedUp`, `Snapshot.nextCall` and `linedUp`, the `next-call` and `lined-up` events (`snapshot.nextCall.idle`, `nextCall.*`, `linedUp.*`) |
386
386
  | a harness that exercised an adapter, drove a call and evaluated rules | the harness reads as a test: `testAdapter`, `withCall`, `timeoutMs`, `notTested`, `rulesTested`, rules `test.*` (`test.timeout`, `test.offer.expired`, `test.preview.deadline`, ...) |
387
+ | a `callback-requested` step, and a callback task starting its history afresh | a `callback-requested` step (`by` when an agent arranged it); a callback's task carries the earlier call's steps, oldest first |
388
+ | a recording state with an observation id, an observation instant and a validity window, and the helpers that aged it | `recordings` on the task and on `HostRecordingReport`: a list, each recording with `id`, `follows` (`party` or `agent`), `status` (`starting`, `recording`, `paused`, `not-recording`, `unknown`) and a `reason` where paused or unknown; nothing expires; commands name `follows` for a start and `recordingId` otherwise; `validateRecordings` (`recording.task.renamed` refuses the former field) |
389
+ | a refusal on hold under `omni.command-not-permitted` | `omni.on-hold` |
387
390
  | an `end-call` while the caller was on hold | refused: a `paused` task is resumed first (`command.endCall.held`), the agent computer shows End call disabled on hold, the provider answers `failed`, and the test names an adapter that ends from hold (`test.command.held`); `terminate-call` is not gated, it ends the caller's channel too |
388
391
  | the handset spelled by its place on a desk | `hardphone`, on the manifest's `phones` and the login's `phone`; the former spelling is refused |
389
392
  | the desk | the agent computer, in the guide's prose |
@@ -426,7 +429,20 @@ something else, which will arrive under its own name and must not be folded in h
426
429
  It rides in the snapshot and is replaced whole like everything else there.
427
430
 
428
431
  Steps are `queued`, `offered`, `answered`, `held`, `muted`, `transferred`, `conferenced`,
429
- `unanswered`, and each is defined on `TaskHistoryStep`.
432
+ `unanswered`, `callback-requested`, and each is defined on `TaskHistoryStep`.
433
+
434
+ **A callback carries the call it comes back to.** When a caller asks to be called back, or an agent
435
+ arranges it, the provider writes a `callback-requested` step: `by` names the agent who arranged it, and
436
+ is absent when the caller chose it in the queue. The callback reaches an agent as a new task, and that
437
+ task's history carries the earlier call's steps, oldest first, the `callback-requested` step among them,
438
+ then the callback's own -- as a hand-over carries the steps before it. The agent opening a callback
439
+ sees that the caller waited and asked, or whose promise they are keeping, and a second try reads as
440
+ one, the way contact-centre platforms keep a callback with its original contact.
441
+
442
+ **One call has one history.** Every task the call reaches restates the earlier steps exactly as they
443
+ stood: the same step, instant, person, dial and destination, and a duration once stated never
444
+ changes. `assertHistoryAgreesAcrossTasks(earlier, later)` holds two tasks of one call to that, for an
445
+ adopter's own test that drives a hand-over, take-over or callback.
430
446
 
431
447
  **The record is one entry per occurrence, oldest first.** Each hold is its own `held` entry — `at`
432
448
  when it began, `seconds` once it ended and omitted while it runs — and a second hold is a second
@@ -1099,6 +1115,7 @@ react rather than only display the message:
1099
1115
  | `omni.unavailable` | The provider is temporarily unable to serve the action, including any command sent while `transport-status` is not `active`. |
1100
1116
  | `omni.break-already-committed` | Cancellation lost the commit/cancel race; Omni must finish commit recovery. |
1101
1117
  | `omni.recording-unsettled` | A recording command the provider could not settle either way: a settled `failed`, never a promise left unresolved. See **Independent task recording** in `guide/voice.md`. |
1118
+ | `omni.on-hold` | Refused because the caller is on hold: the agent application's own Mute begun on a `paused` task, or `end-call` there. The agent application says resume first. The test expects this code on both (`test.recordStep.held`, `test.command.held`). |
1102
1119
  | `omni.break-forced-by-provider` | A lead asked to lift a break the platform imposed; the platform lifts its own. See **Forced breaks** in `guide/breaks.md`. |
1103
1120
 
1104
1121
  They are published as `OMNI_FAILURE_CODES`.
package/guide/phone.md CHANGED
@@ -238,7 +238,7 @@ softphone the agent application's own Mute is unavailable while the task is `pau
238
238
  promised at login (`muteUnavailableOnHold`): mute then hold is ordinary, and the muted leg runs on
239
239
  across the held one; hold then mute is not, and a `muted` leg with `mutedBy: "host"` beginning on a
240
240
  paused task is held back by the agent application (`historyReport.muted.held`) and refused by a
241
- provider that receives one anyway. The test sends one past the validator while the task is held
241
+ provider that receives one anyway, `failed` with `omni.on-hold`. The test sends one past the validator while the task is held
242
242
  and names an adapter that records it (`test.recordStep.held`). A station mute the agent
243
243
  application observes on hold -- the headset's slider, the operating system -- is a fact, reported
244
244
  with `mutedBy: "station"`, and stands. The agent application reports every such period
package/guide/voice.md CHANGED
@@ -262,7 +262,7 @@ provider decides: another IVR, a queue, a survey, or the end of the call. Gated
262
262
  with nobody coming back to them, and the provider's path for a caller who continues never begins.
263
263
  So the caller is taken off hold first: the agent computer holds the command back while the task is `paused`
264
264
  (`command.endCall.held`) and shows End call disabled on hold, and a provider that receives it
265
- anyway answers `failed`. Resume, then end. `terminate-call` needs no such gate: it ends the
265
+ anyway answers `failed` with `omni.on-hold`, so the agent application can say resume first. Resume, then end. `terminate-call` needs no such gate: it ends the
266
266
  caller's channel with the rest, held or not, and nobody is left waiting.
267
267
 
268
268
  **`terminate-call` ends the whole call.** Every channel on it ends at the provider: the agent's,
@@ -463,114 +463,120 @@ agent's.
463
463
  ## Independent task recording
464
464
 
465
465
  Recording is voice-only. A task can arrive already recording, including while pending, and offer
466
- no recording controls. The provider publishes only its own current state on
467
- the provider field of the task’s `recording` state. The agent application publishes its own full scoped view in `HostReport.recordings`.
468
- A provider task update cannot replace agent application state. Neither a capability nor an accepted command
469
- establishes recording. Missing state, lost recorder observation, transport loss and expired
470
- observation mean unknown; they never mean stopped. Paused means a recording remains open without
471
- capturing. Task hold, task pause and recording pause are independent.
472
-
473
- The per-task policy is `Task.capabilities.recording`, with independently optional provider and agent application
474
- action sets. The outer capability may be locked. Absence grants no permission, and a state may
475
- exist without any permission. A provider may offer only stop for a recording started automatically,
476
- or withdraw a control on a later task update. There is no global recording mode and no automatic
477
- start from a capability declaration. Agent application support is declared on `ConnectContext.host.recording`,
478
- not the provider-owned manifest. The task names one of the storage places the agent application provisioned, by `storageId`;
479
- an unknown storage place or an unsupported action is refused visibly, never redirected to provider
480
- recording or a default upload location. Initial agent application support requires voice softphone audio and
481
- capture of both local and remote audio; an agent application unable to capture either must refuse start/resume.
482
- This package declares that contract; it supplies no recorder, audio mixing, storage or upload.
483
-
484
- | Action | Required current recording state | Confirmed outcome |
485
- | --- | --- | --- |
486
- | start | inactive | active with a new recording identity |
487
- | pause | active | paused, preserving recording identity and captured audio |
488
- | resume | paused | active with the same recording identity |
489
- | stop | active or paused | inactive; finish and retain captured audio |
490
- | cancel | active or paused | inactive; abandon and discard captured audio |
491
-
492
- Stop finishes the recording and retains captured audio. Cancel abandons the recording and discards
493
- its captured audio; the task policy offers it with `cancel: true`. There is no configurable cancel
494
- effect. A recorder that cannot confirm completion must not offer Cancel; it can offer Stop instead.
495
- Cancel never means cancelling an in-flight start request or deleting arbitrary past recordings.
496
- Stop can be applied only after finalization/retention succeeds; Cancel only after both cessation
497
- and completion of this recording's audio succeed under the recorder's storage contract.
498
- Partial success -- capture stopped but the storage outcome unknown, a start the recorder cannot
499
- vouch for either way -- is a settled `failed`, under the code `omni.recording-unsettled`: the
500
- command did not do what it promised, and the state the provider publishes with it is whatever
501
- capture state is actually known. It is never `applied`, and never an unsettled promise the agent
502
- application would be left to resync, since the provider knows exactly what it could not settle.
503
- There is no request identity on a recording command: the outcome is the state, and what the
504
- agent sees is the state. Retention is not a promise of sample-perfect audio.
505
-
506
- Provider commands go exclusively to `Connection.execute`; agent application commands go exclusively to
507
- `HostRecording.execute`. Both include the assignment and observation identities; all
508
- non-start commands identify the particular recording. IDs are opaque and scoped by provider login,
509
- task and recorder owner. They are never inferred from filenames or current agent identity. A
510
- recording ID survives pause/resume and reassignment only where the same recorder confirms continuity;
511
- commands always name the current assignment. A later start gets a different ID. Reconnect does not
512
- create a new recording or fresh evidence. After lost continuity, use unknown until reconciled.
513
- There is no automatic restart, hand-over to another recorder, or stop on task hold/disconnect.
514
- Task removal does not prove recording stopped: outstanding agent application recorders remain tracked by the
515
- agent application until its executor reconciles/finishes them, with visible unresolved cleanup failures.
516
-
517
- Only start/resume require an in-progress or paused task with started audio. Pause/stop/cancel may
518
- also finish an independently observed recorder while the task is completing. A pending task may
519
- show active recording, but agent controls wait until interaction begins. The two recording paths may
520
- both be active. A command to either path has no implied effect on the other.
521
-
522
- Each confirmed observation has a fresh opaque observation identity, a canonical UTC millisecond
523
- observation instant and an exclusive expiry. These times describe current evidence, never historical
524
- capture boundaries. A trusted observer-domain current time must satisfy observedAt <= now < validUntil.
525
- Use `effectiveRecordingState` with that explicitly trusted time; an unavailable clock yields unknown.
526
- Receipt, replay and task publication never extend freshness. Clock discontinuity invalidates evidence;
527
- consumers using the explicit recording-expiry clock-estimation exception must invalidate that
528
- estimate and use monotonic aging so clock rollback cannot
529
- revive expired evidence. The executor compares observation identity and recording identity against
530
- its latest state atomically before I/O. An intervening observation or assignment change refuses the
531
- stale command; it never acts on a replacement recorder. Providers lacking trustworthy current-state
532
- identity or observation time publish unknown and offer no issuable controls.
533
-
534
- Use `validateTask` and `validateTaskCommand` for published shape and static permissions.
535
- Immediately before dispatch, additionally use `validateRecordingRequest` against the full current
536
- task and explicit observer-domain time. For agent application commands also supply the current agent application declaration,
537
- full agent application report and softphone context. The same checks must run at the executing boundary;
538
- client validation alone is not authorization. The executor serializes operations on each recorder
539
- and compares `observationId` and `recordingId` against its latest evidence before acting: a
540
- command against superseded evidence is refused, and nothing is retried on the executor's own
541
- account.
542
-
543
- Keep command progress in agent application UI separately from authoritative state. Applied means the requested
544
- recording transition and any retain/discard effect actually completed; publish the confirming task or agent application report before
545
- resolving applied. Failed means confirmed no effect. Rejected promise means outcome unknown:
546
- show the failure, reconcile that path and do not automatically retry or pretend inactive. Authoritative
547
- updates remain full current task/agent application views, not replayed provider events. These current-state fields
548
- create no interaction-history entries and infer no actors, durations or historical capture boundaries.
549
-
550
- Migration is explicit: replace the old true recording capability with per-path action policies,
551
- replace untargeted commands with scoped commands, and retain protocol version 1. During pre-release
552
- development, hosts and adapters must align their recording contracts; negotiation does not detect
553
- differences between package revisions that share that protocol number. No legacy-shape fallback is provided.
554
- This change does not publish a package or enable recording in any existing agent application/provider by itself.
555
-
556
- `validateRecordingOutcome` checks confirming observations against recording-specific applied/failed
557
- semantics, including pause/resume identity and rejection of a dialling result. It cannot prove audio
558
- retention/completion or source truth from a status flag; those remain executor obligations.
559
-
560
- The storage place named on the task is bound when start actually creates a recording. Existing recordings
561
- keep that binding through pause/resume/stop/cancel; a later policy cannot redirect their stored audio.
562
- The executor rejects a mismatched storage place rather than moving or discarding another binding.
563
- Action permission does not authorize unattended invocation: agent application controls require the agent's explicit
564
- act, and provider authorization remains enforced at its authenticated command boundary.
565
-
566
-
567
- Recording dispatch must receive the same known task-validation context as the published task.
568
- Pass organisation levels and other known restrictions through the optional fourth argument of
569
- `validateTaskCommand`, and through `taskContext` in `validateRecordingRequest`. This keeps a valid
570
- custom organisation lock from being rejected against the default ladder, and prevents known
571
- capability restrictions from disappearing at dispatch. The standalone `validateRecordingCommandState`
572
- requires an affirmative permission; false, malformed permission declarations and unknown actions grant nothing.
466
+ no recording controls. **Each recorder publishes its own recordings, as a list, whenever one
467
+ changes.** The provider's are the task's `recordings`, restated whole on every publication of the task
468
+ and on every snapshot. The agent application's are on its `HostReport.recordings`, one entry per
469
+ assignment, never on a provider's task. A provider task update cannot replace the agent
470
+ application's recordings, and a command to one recorder has no effect on the other.
471
+
472
+ ```json
473
+ "recordings": [
474
+ { "id": "rec-7", "follows": "party", "status": "recording" },
475
+ { "id": "rec-8", "follows": "agent", "status": "paused", "reason": "sensitive-details" },
476
+ { "id": "rec-3", "follows": "party", "status": "not-recording" }
477
+ ]
478
+ ```
573
479
 
480
+ **A recording follows one side of the call.** `follows` says whose channel it captures: the
481
+ `party`'s, or the `agent`'s own. The two are separate captures on most platforms, and the agent
482
+ computer needs to know which: a recording that follows the party keeps capturing hold music
483
+ while the agent talks to a colleague, and only one that follows the agent says whether the
484
+ agent's own voice is on it. Each side has at most one recording that is, or may be, capturing at
485
+ once -- `starting`, `recording`, `paused` or `unknown` (`recording.follows.live`) -- and any number
486
+ that have finished.
487
+
488
+ **Nothing expires.** A recording stands as its recorder last stated it until the recorder says
489
+ otherwise. There is no observation time, no validity window and no clock to compare: everything
490
+ the agent computer shows comes from the wire, and a recording mark that went dark when a recorder
491
+ check ran late, then came back, was a fault reported from the floor. The recorder publishes on
492
+ change and restates the whole list on connect and resume.
493
+
494
+ | Status | Meaning |
495
+ | --- | --- |
496
+ | `starting` | Asked for and not yet confirmed by the recorder. |
497
+ | `recording` | Capturing. A pause or a stop still in flight stays `recording` until the recorder confirms it: telling the agent they are not recorded when they may be is the one thing this must never say. |
498
+ | `paused` | Open but not capturing, with a `reason`: `requested`, someone asked for it, or `sensitive-details`, the platform paused it while card or account details are taken. |
499
+ | `not-recording` | Finished, or never started on that side; captured audio is kept or discarded as its stop or cancel said. |
500
+ | `unknown` | The recorder cannot say, with a `reason`: `recorder-unreachable`, or `unsettled`, a command whose effect it could not establish either way. Never shown as stopped. |
501
+
502
+ A mark that is not lit says why: `paused` and `unknown` carry a reason from those closed lists, and
503
+ nothing else carries one (`recording.reason`, `.reason.unexpected`). Task hold, task pause and
504
+ recording pause are independent.
505
+
506
+ **Permission is per task.** The policy is `Task.capabilities.recording`, with independently
507
+ optional provider and agent application action sets. The outer capability may be locked. Absence
508
+ grants no permission, and a recording may exist without any permission. A provider may offer only
509
+ stop for a recording started automatically, or withdraw a control on a later task update. There is
510
+ no global recording mode and no automatic start from a capability declaration. Agent application
511
+ support is declared on `ConnectContext.host.recording`, not the provider-owned manifest. The task
512
+ names one of the storage places the agent application provisioned, by `storageId`; an unknown
513
+ storage place or an unsupported action is refused visibly, never redirected to provider recording
514
+ or a default upload location. Initial agent application support requires voice softphone audio and
515
+ capture of both local and remote audio; an agent application unable to capture either must refuse
516
+ start/resume. This package declares that contract; it supplies no recorder, audio mixing, storage
517
+ or upload.
518
+
519
+ **A command names what it acts on.** A start names the side, `follows`; every other action names
520
+ the recording by its `recordingId`. The recorder mints the id when it accepts the start, before
521
+ the recorder has answered, publishes the recording `starting` under it, and keeps it through pause
522
+ and resume; a later start is a new recording with a new id. The recorder serializes the operations
523
+ on each recording and refuses an action the recording no longer stands for: a pause on a recording
524
+ that is not `recording`, a stop on one already `not-recording`, a start on a side already
525
+ capturing, a recording the task does not carry (`recording.command.state`,
526
+ `.command.recordingId`). Nothing is retried on the recorder's own account.
527
+
528
+ | Action | The recording before | Confirmed outcome |
529
+ | --- | --- | --- |
530
+ | start | none live on that side | a new recording on that side, `recording` |
531
+ | pause | `recording` | the same recording, `paused`, captured audio kept |
532
+ | resume | `paused` | the same recording, `recording` |
533
+ | stop | `recording` or `paused` | the same recording, `not-recording`; captured audio finished and kept |
534
+ | cancel | `recording` or `paused` | the same recording, `not-recording`; captured audio discarded |
535
+
536
+ Stop finishes the recording and retains captured audio. Cancel abandons it and discards captured
537
+ audio; the task policy offers it with `cancel: true`, and there is no configurable cancel effect.
538
+ A recorder that cannot confirm completion must not offer Cancel; it can offer Stop instead. Cancel
539
+ never means cancelling an in-flight start or deleting past recordings. Stop is applied only after
540
+ finalization and retention succeed; Cancel only after both cessation and discarding succeed.
541
+ **Applied means confirmed**: the recorder publishes the confirming list before it answers
542
+ `applied`. Failed means confirmed no effect. Partial success -- capture stopped but the storage
543
+ outcome unknown, a start the recorder cannot vouch for either way -- is a settled `failed` under
544
+ `omni.recording-unsettled`, and the recording is published `unknown` with the reason `unsettled`.
545
+ A rejected promise means the outcome is unknown: show the failure, reconcile, and do not retry or
546
+ pretend `not-recording`. There is no request identity on a recording command: the outcome is the
547
+ recording, and what the agent sees is the recording. Retention is not a promise of sample-perfect
548
+ audio.
549
+
550
+ Provider commands go only to `Connection.execute`; agent application commands go only to
551
+ `HostRecording.execute`. Both name the assignment. Ids are opaque and scoped by provider login,
552
+ task and recorder; they are never inferred from filenames or the current agent. Reconnect creates
553
+ no recording; the recorder restates its list. There is no automatic restart, hand-over to another
554
+ recorder, or stop on task hold or disconnect. Task removal does not prove recording stopped:
555
+ outstanding agent application recordings stay tracked by the agent application until its recorder
556
+ finishes them, with unresolved cleanup failures visible.
557
+
558
+ Only start and resume need an `in-progress` or `paused` task with started audio. Pause, stop and
559
+ cancel may also finish a recording while the task is `completing`. A pending task may show a
560
+ recording, but agent controls wait until interaction begins.
561
+
562
+ Use `validateTask` and `validateTaskCommand` for the published shape and static permissions.
563
+ Immediately before dispatch, also use `validateRecordingRequest` against the full current task;
564
+ for agent application commands supply the current declaration, full report and softphone context.
565
+ The same checks run at the recorder; client validation alone is not authorization.
566
+ `validateRecordings` checks one recorder's list, and `validateRecordingOutcome` checks an answer
567
+ against the lists published before and after it: an applied start shows a new recording on its side,
568
+ anything else the same recording in its new status, and a failed one nothing changed there. It
569
+ cannot prove retention or source truth from a status; those remain the recorder's obligations. The
570
+ storage place named on the task is bound when start creates a recording, and later policy cannot
571
+ redirect it.
572
+
573
+ Recording dispatch receives the same known task-validation context as the published task: pass
574
+ organisation levels and other known restrictions through the optional fourth argument of
575
+ `validateTaskCommand`, and through `taskContext` in `validateRecordingRequest`, so a valid custom
576
+ organisation lock is not rejected against the default ladder. Migration is explicit: the former
577
+ `recording` state with its observation and validity window is refused on a task
578
+ (`recording.task.renamed`), and a command carries no observation id. Protocol version 1 stays;
579
+ there is no legacy-shape fallback.
574
580
 
575
581
  ### Agent application recording announcements to the caller
576
582
 
@@ -590,7 +596,7 @@ cancel, they hear that recording stopped. If pause/resume is supported, say paus
590
596
  pause is not presented as a finished recording. Announcements follow actual capture transitions,
591
597
  not button clicks, accepted requests or task-policy changes. When attaching to an already-active
592
598
  agent application recording, announce that recording is active, without inventing its original start time.
593
- Repeated observations of the same state do not repeat announcements. Unknown or expired evidence
599
+ Restating the same status does not repeat an announcement. A recording that is `unknown`
594
600
  must never produce a stopped announcement.
595
601
 
596
602
  A local sound, UI indicator, screen-reader message to the agent, or sound leaking through the
package/guide.md CHANGED
@@ -83,7 +83,7 @@ user IDs, returned user details and directory requirements are unchanged.
83
83
 
84
84
  ### `OMNI_PROTOCOL_VERSION`
85
85
 
86
- The exact protocol version implemented by this package. The current value remains `1` during pre-release development. Recording contract changes do not introduce a new protocol version. Hosts and adapters adopt the current declarations together; this does not provide compatibility mapping for older recording shapes.
86
+ The exact protocol version implemented by this package. The current value remains `1` during pre-release development. Recording contract changes do not introduce a new protocol version. Hosts and adapters adopt the current declarations together; there is no compatibility mapping for older recording shapes.
87
87
 
88
88
  ### `Manifest.supportedProtocolVersions`
89
89
 
@@ -343,23 +343,22 @@ interface TaskRecordingPolicy {
343
343
  provider?: RecordingActions;
344
344
  host?: RecordingActions & { storageId: string };
345
345
  }
346
- type RecordingState =
347
- | { status: "unknown"; observationId?: never; observedAt?: never; validUntil?: never; recordingId?: never }
348
- | ({ observationId: string; observedAt: IsoTimestamp; validUntil: IsoTimestamp } & (
349
- | { status: "inactive"; recordingId?: never }
350
- | { status: "active" | "paused"; recordingId: string }
351
- ));
352
- type RecordingCommand = {
353
- type: "recording";
354
- source: RecordingSource;
355
- observationId: string;
356
- } & (
357
- | { action: "start"; recordingId?: never }
358
- | { action: "pause" | "resume" | "stop" | "cancel"; recordingId: string }
346
+ type RecordingSide = "party" | "agent";
347
+ type RecordingStatus = "starting" | "recording" | "paused" | "not-recording" | "unknown";
348
+ type RecordingPausedReason = "requested" | "sensitive-details";
349
+ type RecordingUnknownReason = "recorder-unreachable" | "unsettled";
350
+ type Recording = { id: string; follows: RecordingSide } & (
351
+ | { status: "starting" | "recording" | "not-recording"; reason?: never }
352
+ | { status: "paused"; reason: RecordingPausedReason }
353
+ | { status: "unknown"; reason: RecordingUnknownReason }
354
+ );
355
+ type RecordingCommand = { type: "recording"; source: RecordingSource } & (
356
+ | { action: "start"; follows: RecordingSide; recordingId?: never }
357
+ | { action: "pause" | "resume" | "stop" | "cancel"; recordingId: string; follows?: never }
359
358
  );
360
359
  interface HostRecordingReport {
361
360
  assignmentId: AssignmentId;
362
- state: RecordingState;
361
+ recordings: Recording[];
363
362
  }
364
363
  interface HostRecording {
365
364
  announcesToCaller?: true;
@@ -694,7 +693,8 @@ type HistoryStep =
694
693
  | "muted"
695
694
  | "transferred"
696
695
  | "conferenced"
697
- | "unanswered";
696
+ | "unanswered"
697
+ | "callback-requested";
698
698
 
699
699
  type TaskHistory = {
700
700
  steps: TaskHistoryStep[];
@@ -777,8 +777,8 @@ type Task<C extends Channel = Channel> = {
777
777
  history?: TaskHistory;
778
778
  } & TaskCompletion & (
779
779
  C extends "voice"
780
- ? { recording?: { provider?: RecordingState }; onCall?: OnCall[]; leadAssist?: TaskLeadAssist; takenOver?: TaskTakenOver; audio?: TaskAudioState }
781
- : { recording?: never; onCall?: never; leadAssist?: never; takenOver?: never; audio?: never }
780
+ ? { recordings?: Recording[]; onCall?: OnCall[]; leadAssist?: TaskLeadAssist; takenOver?: TaskTakenOver; audio?: TaskAudioState }
781
+ : { recordings?: never; onCall?: never; leadAssist?: never; takenOver?: never; audio?: never }
782
782
  );
783
783
 
784
784
  type PreviewDeadline = "provider-dials" | "host-dials" | "waits";
@@ -1345,6 +1345,7 @@ const OMNI_FAILURE_CODES = [
1345
1345
  "omni.unavailable",
1346
1346
  "omni.break-already-committed",
1347
1347
  "omni.recording-unsettled",
1348
+ "omni.on-hold",
1348
1349
  "omni.break-forced-by-provider",
1349
1350
  ] as const;
1350
1351
  type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
@@ -2415,8 +2416,6 @@ The current exceptions and their reasons are:
2415
2416
  | --- | --- |
2416
2417
  | Agent application display of provider deadlines (`expiresInSeconds`, `previewEndsInSeconds`, `wrapEndsInSeconds`) | Each is seconds from the publication that carried it, counted down on the agent computer 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. |
2417
2418
  | Agent application-triggered preview end | The agent computer counts `previewEndsInSeconds` down from receipt and issues `dial` when it reaches zero: the provider owns the deadline and the agent computer owns the trigger. It does not dial before the countdown has run out, and a reload restarts it from the next publication. |
2418
- | 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. |
2419
- | 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. |
2420
2419
  | Direct snapshot reads and method requests/results | These are reads/operations, not event envelopes, and their current types have no general event timestamp. A snapshot event still carries `occurredAt`; embedded source instants remain unchanged. Do not treat a method result or read completion time as an occurrence boundary. |
2421
2420
  | Agent application-provided provider-clock estimate | Best-effort ISO time may be supplied by the agent application for optional comparisons; it is not a source observation or accuracy guarantee. The reason is that the agent application does not own the provider clock. |
2422
2421
  | Provider receipt timestamps | The provider may use its own receipt instant for final records of receipt/processing because agent application timestamps are untrusted advisory input. Receipt must not be presented as an earlier action or capture boundary. |
@@ -2524,14 +2523,13 @@ A provider need not trust or adopt any agent application-supplied timestamp. It
2524
2523
  message using its own clock at receipt and use that for its own processing/accounting. The reason
2525
2524
  for this exception is that agent application clocks and agent application estimates are not authoritative at the provider.
2526
2525
  Receipt time must be described as receipt/observation time, not relabeled as the original agent application
2527
- action, recorder capture boundary, or proof an operation applied. An existing field with a
2526
+ action, a recording's start or stop, or proof an operation applied. An existing field with a
2528
2527
  specific occurrence meaning still requires that evidence; this option does not silently redefine
2529
2528
  it. Agent application-only instants remain advisory input; the provider owns its authoritative publication.
2530
2529
 
2531
2530
  This agent application estimate is another explicit timestamp exception: it helps optional displays and
2532
2531
  provider-clock comparisons when the agent application lacks that clock directly. It is not a source occurrence,
2533
- may not replace event/history timestamps, and alone cannot authorize deadline actions or prove
2534
- recording freshness. Uses requiring a trustworthy bound still need independently established
2532
+ may not replace event/history timestamps, and alone cannot authorize deadline actions. Uses requiring a trustworthy bound still need independently established
2535
2533
  clock accuracy/drift assumptions. It neither synchronizes the operating-system clock nor changes
2536
2534
  ISO timestamps already published by the provider.
2537
2535
 
@@ -2550,7 +2548,9 @@ a backend that does not know the session yet — yields no snapshot, and the ada
2550
2548
 
2551
2549
  A snapshot must account for **everything the adapter has emitted before it resolves**, not merely
2552
2550
  everything emitted when it was requested. Omni drops the state-replacing events held during the
2553
- read on that promise, and applies the rest after it (see **`Connection.snapshot()`**); an adapter
2551
+ read on that promise, and applies the rest after it (see **`Connection.snapshot()`**). Which kinds a
2552
+ snapshot restates, and so supersedes, is exported from the testing module as
2553
+ `SUPERSEDED_BY_A_SNAPSHOT`, so an agent application and the harness drop the same ones; an adapter
2554
2554
  that serves a stale snapshot and then lets an earlier event through will have Omni apply state the
2555
2555
  snapshot already superseded. The harness reads its connect snapshot the same way, and holds the
2556
2556
  snapshot to accounting for every task event it superseded: a task published during the read and
@@ -2713,7 +2713,9 @@ snapshots until it ends.
2713
2713
  with `task-ended` and an outcome, whatever became of the call: answered and completed, declined,
2714
2714
  withdrawn, abandoned in the ring, lapsed, taken over. An offer that is simply never mentioned
2715
2715
  again leaves the agent computer holding a task nobody will close, and the test names it
2716
- (`stream.taskOffered.unended`).
2716
+ (`stream.taskOffered.unended`). **Every dial is owed its outcome** the same way: a dial the test
2717
+ placed and the provider answered `dialling` with no `dial-outcome` by the end of the run is named
2718
+ (`stream.dialOutcome.missing`).
2717
2719
 
2718
2720
  ### `task-updated`
2719
2721
 
@@ -2935,6 +2937,9 @@ same exported checks are used by Omni and adapter tests so their interpretations
2935
2937
  | `validateProviderTimeEstimate(estimate, scope)` | Optional agent application estimate shape and provider/login scope; no accuracy guarantee. |
2936
2938
  | `validateHistoryReport(report, path?, manifest?, task?)` | What the agent application 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`. Given the task, the agent application's own mute beginning on a `paused` task is refused (`historyReport.muted.held`): the Mute waits for resume. |
2937
2939
  | `validateHostReport(report)` | The agent application'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 agent application a test hands the adapter; `stillHost(report)` builds one that never changes. |
2940
+ | `validateNextCall(value)` | The agent's standing ask for the next call, on its own as `next-call` and `Snapshot.nextCall` carry it: since when, and nothing else (`nextCall.since`, `nextCall.field`). |
2941
+ | `validateLinedUp(value, path?, levels?)` | A call lined up in the agent's own queue, on its own as `lined-up` and `Snapshot.linedUp` carry it: the caller under the same contact rules as a task's party, the queue's label, the waits, and `release` by presence (`linedUp.*`). |
2942
+ | `validatePhoneState(value, path?, context?)` | The phone as the platform sees it, on its own as `phone-updated` and `Snapshot.phone` carry it: its status, its one active channel, its mute where a hardphone's is observed (`phone.*`). |
2938
2943
  | `validateHostMute(mute, softphone)` | What the agent application's Mute does, stated on a softphone login and nowhere else: `stream` or `station` (`host.mute`), required where the agent application holds a microphone (`host.mute.required`) and refused where it does not (`host.mute.unexpected`). The harness holds `ConnectContext.host.mute` to it. |
2939
2944
  | `validateLoginStore(store)` | The login's store the agent application hands every connection: an object with `get`, `set` and `delete` (`store.shape`, `store.get`, `.set`, `.delete`). The harness holds `ConnectContext.store` to it. |
2940
2945
  | `validateCapacity(capacity)` | What the agent application states as capacity: a whole number of zero or more (`capacity.count`), zero being agent application-stopped. The harness states one on connect and two, one and zero after the test, each answered `applied`, and holds any offer to the count in force (`stream.taskOffered.overCapacity`). |
@@ -3144,6 +3149,7 @@ cannot be established from TypeScript structure alone.
3144
3149
  | `TaskStream`, `BreakStream` | The cross-event models the harness applies after the connect snapshot, exported for an agent application that wants the same rules at its boundary: `seed(snapshot)`, then `apply(envelope)` returns the violations. |
3145
3150
  | `assertBreakFollowsItsRequests(envelopes, snapshot?)` | A break follows its requests: a commit's states only after a grant, never backwards, and a forced break arriving in effect with `forced`. The harness applies the same rules after the connect snapshot. |
3146
3151
  | `assertAudioFollowsTheTask(envelopes, snapshot?)` | The audio follows the task and never decides it: every task is introduced once, `task-audio-started` and `task-audio-ended` alternate on work that has begun, audio ends only where it arrived, and what follows the audio ending is `completing` or `task-ended`. The harness applies the same rules to every event after the connect snapshot (`stream.*`). A sequence with no audio satisfies it by never testing it — pair it with the assertion that the audio end is present. |
3152
+ | `assertHistoryAgreesAcrossTasks(earlier, later)` | Two tasks of one call -- a hand-over, a take-over, a callback: the later restates every earlier step in order, with the same step, instant, person, dial and destination, and any duration already stated unchanged. Throws when either carries no history. |
3147
3153
  | `assertBreakAttemptProviders(candidates, asked)` | A break attempt asks every usable provider holding capacity, `refreshing` included, and nothing of a provider whose login is `expired`. |
3148
3154
  | `assertForcedBreakStopsTheRest(forcedOn, candidates, stopped)` | A forced break on one provider stops the agent everywhere else: capacity zero stated on every other usable provider holding capacity, nothing on the forcing one, nothing on a dead login. See **Forced breaks** in `guide/breaks.md`. |
3149
3155
  | `assertBreakBeginsAfterTask(steps)` | A break asked for on a task is committed as `starting-after-task` while work remains and reaches `on-break` only once nothing is outstanding — never beside a task, never later than the step that has none. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.117",
3
+ "version": "0.1.119",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",