@xema/omni-protocol 0.1.80 → 0.1.82

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
@@ -427,7 +427,85 @@ export type HostAudioOutput =
427
427
  */
428
428
  /** What Omni's own chrome shows of a task browser's URL: nothing, the domain, or all of it. */
429
429
  export type UrlVisibility = "full" | "domain" | "hidden";
430
+ /** Independent recorder owners. A command never targets both. */
431
+ export type RecordingSource = "provider" | "host";
432
+ export type RecordingAction = "start" | "pause" | "resume" | "stop" | "cancel";
433
+ export declare const RECORDING_ACTIONS: readonly ["start", "pause", "resume", "stop", "cancel"];
434
+ export interface RecordingActions {
435
+ start?: true;
436
+ pause?: true;
437
+ resume?: true;
438
+ stop?: true;
439
+ /** Abandon this recording and discard its captured audio. */
440
+ cancel?: true;
441
+ }
442
+ /** Per-task permission, not a provider-wide recording switch. Absence grants nothing. */
443
+ export interface TaskRecordingPolicy {
444
+ provider?: RecordingActions;
445
+ host?: RecordingActions & {
446
+ destinationId: string;
447
+ };
448
+ }
449
+ /** Current evidence only. Expiry/disconnect means unknown, never a historical stop. */
450
+ export type RecordingState = {
451
+ status: "unknown";
452
+ observationId?: never;
453
+ observedAt?: never;
454
+ validUntil?: never;
455
+ recordingId?: never;
456
+ } | ({
457
+ observationId: string;
458
+ observedAt: IsoTimestamp;
459
+ validUntil: IsoTimestamp;
460
+ } & ({
461
+ status: "inactive";
462
+ recordingId?: never;
463
+ } | {
464
+ status: "active" | "paused";
465
+ recordingId: string;
466
+ }));
467
+ /** Opaque IDs are scoped by login, task and recorder owner; preserved across pause/resume. */
468
+ export type RecordingCommand = {
469
+ type: "recording";
470
+ source: RecordingSource;
471
+ requestId: string;
472
+ /** Compare-and-set against current evidence; never act on a replacement recording. */
473
+ observationId: string;
474
+ } & ({
475
+ action: "start";
476
+ recordingId?: never;
477
+ } | {
478
+ action: "pause" | "resume" | "stop" | "cancel";
479
+ recordingId: string;
480
+ });
481
+ export interface HostRecordingReport {
482
+ taskId: TaskId;
483
+ allocationId: AllocationId;
484
+ state: RecordingState;
485
+ }
486
+ /** Host declaration in ConnectContext, not the provider-owned Manifest. */
487
+ export interface HostRecording {
488
+ /** Audible recording status announcements to the remote party, for host-owned capture only. */
489
+ announcesToCaller?: true;
490
+ actions: RecordingAction[];
491
+ /** Explicitly provisioned destinations. A task chooses one; no upload/storage fallback. */
492
+ destinationIds: string[];
493
+ execute(request: HostRecordingRequest): Promise<RecordingCommandResult>;
494
+ }
495
+ /** Recording applies or fails; it never dials. Rejection without result means unknown. */
496
+ export type RecordingCommandResult = Exclude<TaskCommandResult, {
497
+ status: "dialling";
498
+ }>;
499
+ export interface HostRecordingRequest {
500
+ taskId: TaskId;
501
+ allocationId: AllocationId;
502
+ command: RecordingCommand & {
503
+ source: "host";
504
+ };
505
+ }
430
506
  export interface HostReport {
507
+ /** Full current host-owned recording view; never provider-owned state. */
508
+ recordings?: HostRecordingReport[];
431
509
  /** Whether the host has a network interface up. Not a claim that anything is reachable: the adapter knows its own platform's reachability better than the host does. */
432
510
  online: boolean;
433
511
  audio?: {
@@ -449,6 +527,8 @@ export interface HostGuarantees {
449
527
  personConsent?: true;
450
528
  }
451
529
  export interface Host {
530
+ /** Voice softphone recording support; policy and destination remain per task. */
531
+ recording?: HostRecording;
452
532
  guarantees: HostGuarantees;
453
533
  /** What pressing Mute does on this host. Stated on a softphone login, where the host holds the microphone; absent on a desk phone and off voice. */
454
534
  mute?: HostMute;
@@ -494,6 +574,7 @@ export type ConnectContext = {
494
574
  phone?: "deskPhone";
495
575
  host: {
496
576
  mute?: never;
577
+ recording?: never;
497
578
  };
498
579
  });
499
580
  export type TransportStatus = "connecting" | "active" | "error";
@@ -581,7 +662,7 @@ export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ?
581
662
  /** Ask a lead to join this call, with a note. The lead's decision arrives on `Task.leadAssist`. */
582
663
  leadAssist?: Lockable<true>;
583
664
  conference?: Lockable<DestinationDirectory>;
584
- recording?: Lockable<true>;
665
+ recording?: Lockable<TaskRecordingPolicy>;
585
666
  } : C extends "chat" ? SharedTaskCapabilities & {
586
667
  decline?: Lockable<true>;
587
668
  hold?: Lockable<true>;
@@ -888,12 +969,16 @@ export type Task<C extends Channel = Channel> = {
888
969
  attributes?: TaskAttribute[];
889
970
  handlingHistory?: TaskHandlingHistory;
890
971
  } & TaskCompletion & (C extends "voice" ? {
972
+ recording?: {
973
+ provider?: RecordingState;
974
+ };
891
975
  onCall?: OnCall[];
892
976
  leadAssist?: TaskLeadAssist;
893
977
  assisting?: TaskAssisting;
894
978
  monitoring?: TaskMonitoring;
895
979
  media?: TaskMediaState;
896
980
  } : {
981
+ recording?: never;
897
982
  onCall?: never;
898
983
  leadAssist?: never;
899
984
  assisting?: never;
@@ -1040,10 +1125,9 @@ export type VoiceTaskCommand = {
1040
1125
  action: "remove";
1041
1126
  party: true;
1042
1127
  destinationId?: never;
1043
- } | {
1044
- type: "recording";
1045
- action: "start" | "pause" | "resume" | "stop";
1046
- } | ({
1128
+ } | (RecordingCommand & {
1129
+ source: "provider";
1130
+ }) | ({
1047
1131
  type: "complete";
1048
1132
  } & DispositionPayload);
1049
1133
  export type ChatTaskCommand = {
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ export const IDLE_CAPABILITY_UI = {
29
29
  export const MONITOR_MODES = ["monitor", "whisper", "barge"];
30
30
  export const MUTED_BY = ["host", "station"];
31
31
  export const HOST_MUTES = ["stream", "station"];
32
+ export const RECORDING_ACTIONS = ["start", "pause", "resume", "stop", "cancel"];
32
33
  // ---------------------------------------------------------------------------
33
34
  // Task workspace.
34
35
  // ---------------------------------------------------------------------------
package/dist/testing.d.ts CHANGED
@@ -220,6 +220,7 @@ export declare function stillHost(report: HostReport, guarantees: HostGuarantees
220
220
  };
221
221
  export declare function stillHost(report?: HostReport, guarantees?: HostGuarantees): Host & {
222
222
  mute?: never;
223
+ recording?: never;
223
224
  };
224
225
  /** One provider as the host sees it when freezing the providers a break attempt asks. */
225
226
  export interface BreakCandidate {
package/dist/testing.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
2
- import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateDescribedUsers, observeRules, ruleEvaluated, validateHostMute, validateLoginStore, validateHandlingReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
2
+ import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateDescribedUsers, observeRules, ruleEvaluated, validateHostMute, validateHostRecording, validateLoginStore, validateHandlingReport, validateManifest, validateResult, validateSnapshot, validateTask, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
3
3
  export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
4
4
  /**
5
5
  * A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
@@ -267,6 +267,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
267
267
  // audio and reports it; on a desk phone, or off voice, there is none for it to report.
268
268
  violations.push(...validatePhone(context.phone, adapter.manifest, "context.phone"));
269
269
  const softphone = adapter.manifest.channel === "voice" && context.phone === "softphone";
270
+ violations.push(...validateHostRecording(context.host.recording, softphone));
270
271
  const first = context.host.report();
271
272
  violations.push(...validateHostReport(first, "context.host"));
272
273
  const hasAudio = isRecord(first) && first.audio !== undefined;
@@ -291,6 +292,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
291
292
  // The wrapped host carries the mute kind the test's host stated, so the arms of ConnectContext still hold.
292
293
  const host = {
293
294
  guarantees: context.host.guarantees,
295
+ ...(context.host.recording === undefined ? {} : { recording: context.host.recording }),
294
296
  ...(context.host.mute === undefined ? {} : { mute: context.host.mute }),
295
297
  report: () => { consulted.report = true; return context.host.report(); },
296
298
  subscribe: listener => { consulted.subscribe = true; return context.host.subscribe(listener); },
@@ -870,11 +872,11 @@ export function assertTaskCapabilityWithdrawal(tasks, manifest, command) {
870
872
  if (withdrawn.length === 0) {
871
873
  throw new Error("Task capability withdrawal must end with at least one capability the offer declared withdrawn; a locked control is present without permission, not withdrawn");
872
874
  }
873
- const before = validateTaskCommand(command, first, "command");
875
+ const before = validateTaskCommand(command, first, "command", context);
874
876
  if (before.length > 0) {
875
877
  throw new Error(`Task capability withdrawal: the command must be issuable against the task as offered, or its later refusal proves nothing (${before.map(v => v.rule).join(", ")})`);
876
878
  }
877
- const after = validateTaskCommand(command, last, "command");
879
+ const after = validateTaskCommand(command, last, "command", context);
878
880
  const forWant = after.filter(v => withdrawn.some(name => v.rule === `command.capability.${name}`));
879
881
  if (forWant.length === 0) {
880
882
  throw new Error(`Task capability withdrawal: the command must be refused against the republished task for want of ${withdrawn.join(" or ")}; it was ${after.length === 0 ? "accepted" : `refused for ${after.map(v => v.rule).join(", ")}`}`);
@@ -1558,7 +1560,11 @@ async function driveOneCall(drive) {
1558
1560
  // Every command the drive sends is validated against the task as published, and its answer for its method.
1559
1561
  ruleEvaluated("drive.timeout");
1560
1562
  const send = async (command, dialId) => {
1561
- const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`);
1563
+ const own = validateTaskCommand(command, latestTask(), `drive.command.${String(command.type)}`, {
1564
+ levels: effectiveLevels(drive.manifest.orgLevels).map(level => level.id),
1565
+ dialOutcomesDeclared: drive.manifest.dialOutcomes !== undefined,
1566
+ autoAcceptTasks: drive.context.autoAcceptTasks,
1567
+ });
1562
1568
  found.push(...own);
1563
1569
  if (own.length > 0)
1564
1570
  return undefined;
@@ -1,4 +1,4 @@
1
- import { type ProtocolViolation, type UserCapabilities, type UserId } from "./index.js";
1
+ import { type RecordingState, 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[];
@@ -100,7 +100,7 @@ export declare function validateHostReport(report: unknown, path?: string): Prot
100
100
  * own shape is checked. A host validates before sending, and an adapter before acting: a command
101
101
  * for a control the task never offered is the host's error, and this names it.
102
102
  */
103
- export declare function validateTaskCommand(command: unknown, task?: unknown, path?: string): ProtocolViolation[];
103
+ export declare function validateTaskCommand(command: unknown, task?: unknown, path?: string, taskContext?: Omit<TaskValidationContext, "channel">): ProtocolViolation[];
104
104
  /** The connection methods whose results `validateResult` knows. */
105
105
  export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamLeadAssist" | "executeTeamMonitor" | "openMedia" | "setPreference" | "recordStep" | "executeTeamPolicy";
106
106
  /**
@@ -135,3 +135,24 @@ export declare function validateAuthenticationFailure(failure: unknown, path?: s
135
135
  */
136
136
  export declare function validateAuthenticationResult(result: unknown, method: "start" | "complete", path?: string, context?: LoginValidationContext): ProtocolViolation[];
137
137
  export declare function validateAuthenticationState(state: unknown, path?: string, context?: LoginValidationContext): ProtocolViolation[];
138
+ export declare function validateRecordingState(value: unknown, path?: string): ProtocolViolation[];
139
+ /** Supply a trusted current instant in the observer's clock domain. Undefined means clock unknown. */
140
+ export declare function effectiveRecordingState(state: RecordingState | undefined, now: number | undefined): RecordingState;
141
+ export declare function validateRecordingPolicy(value: unknown, path?: string): ProtocolViolation[];
142
+ export declare function validateHostRecording(value: unknown, softphone: boolean, path?: string): ProtocolViolation[];
143
+ export declare function validateHostRecordings(value: unknown, path?: string): ProtocolViolation[];
144
+ export declare function validateRecordingCommandShape(value: unknown, source: "provider" | "host", path?: string): ProtocolViolation[];
145
+ /** Structural/state permission check. Dispatch MUST also call validateRecordingRequest for scope and freshness. */
146
+ export declare function validateRecordingCommandState(command: unknown, policy: unknown, state: unknown, path?: string): ProtocolViolation[];
147
+ /** Validate immediately before dispatch using current task, host report and trusted observer-domain time. */
148
+ export declare function validateRecordingRequest(request: unknown, task: unknown, context: {
149
+ source: "provider" | "host";
150
+ now: number | undefined;
151
+ host?: unknown;
152
+ hostReport?: unknown;
153
+ softphone?: boolean;
154
+ /** The same provider/task context used to validate the published task. */
155
+ taskContext?: Omit<TaskValidationContext, "channel">;
156
+ }, path?: string): ProtocolViolation[];
157
+ /** Check recording-specific result semantics against the owner's confirming observations. */
158
+ 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 { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, MONITORING_BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HANDLING_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, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, MONITORING_BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HANDLING_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") {
@@ -1116,6 +1116,17 @@ function validateTaskInto(task, context, path, into) {
1116
1116
  if (context.locked !== undefined && isPlainObject(task.party) && (isLocked(task.party.number) || isLocked(task.party.email))) {
1117
1117
  validateNothingLeaksInto(task, context.locked, path, into);
1118
1118
  }
1119
+ if (task.recording !== undefined) {
1120
+ into.require(context.channel === "voice", "recording.task.channel", `${path}.recording`, "recording is voice-only");
1121
+ if (!isPlainObject(task.recording))
1122
+ into.add("recording.task.shape", `${path}.recording`, "expected provider recording state");
1123
+ else {
1124
+ for (const key of Object.keys(task.recording))
1125
+ into.require(key === "provider", "recording.task.owner", `${path}.recording.${key}`, "host state belongs in HostReport, never provider task updates");
1126
+ if (task.recording.provider !== undefined)
1127
+ into.violations.push(...validateRecordingState(task.recording.provider, `${path}.recording.provider`));
1128
+ }
1129
+ }
1119
1130
  validateBrowsers(task.browsers, `${path}.browsers`, into);
1120
1131
  validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
1121
1132
  validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into, { phase: task.phase, media: task.media });
@@ -1181,6 +1192,9 @@ function validateTaskInto(task, context, path, into) {
1181
1192
  continue;
1182
1193
  }
1183
1194
  switch (name) {
1195
+ case "recording":
1196
+ into.violations.push(...validateRecordingPolicy(declared, `${path}.capabilities.recording`));
1197
+ break;
1184
1198
  case "dispositions":
1185
1199
  validateDispositions(declared, `${path}.capabilities.dispositions`, into);
1186
1200
  break;
@@ -1946,6 +1960,7 @@ export function validateHostReport(report, path = "host") {
1946
1960
  return into.violations;
1947
1961
  }
1948
1962
  into.require(typeof report.online === "boolean", "host.online", `${path}.online`, "a host report says whether it has a network");
1963
+ into.violations.push(...validateHostRecordings(report.recordings, `${path}.recordings`));
1949
1964
  if (report.audio === undefined)
1950
1965
  return into.violations;
1951
1966
  if (!isPlainObject(report.audio)) {
@@ -2005,7 +2020,6 @@ export function validateHostReport(report, path = "host") {
2005
2020
  const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
2006
2021
  const LEAD_ASSIST_ACTIONS = ["request", "cancel", "take-over", "leave"];
2007
2022
  const CONFERENCE_ACTIONS = ["add", "remove"];
2008
- const RECORDING_ACTIONS = ["start", "pause", "resume", "stop"];
2009
2023
  /**
2010
2024
  * What a command needs to be issuable, checked against the task it names: the capability the
2011
2025
  * guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
@@ -2013,7 +2027,7 @@ const RECORDING_ACTIONS = ["start", "pause", "resume", "stop"];
2013
2027
  * own shape is checked. A host validates before sending, and an adapter before acting: a command
2014
2028
  * for a control the task never offered is the host's error, and this names it.
2015
2029
  */
2016
- export function validateTaskCommand(command, task, path = "command") {
2030
+ export function validateTaskCommand(command, task, path = "command", taskContext) {
2017
2031
  const into = new Collector();
2018
2032
  if (!isPlainObject(command)) {
2019
2033
  into.add("command.shape", path, "a command must be an object");
@@ -2068,7 +2082,7 @@ export function validateTaskCommand(command, task, path = "command") {
2068
2082
  }
2069
2083
  break;
2070
2084
  case "recording":
2071
- into.oneOf(command.action, RECORDING_ACTIONS, "command.recording.action", `${path}.action`);
2085
+ into.violations.push(...validateRecordingCommandShape(command, "provider", path));
2072
2086
  break;
2073
2087
  case "complete":
2074
2088
  if (command.disposition !== undefined)
@@ -2086,7 +2100,7 @@ export function validateTaskCommand(command, task, path = "command") {
2086
2100
  // checking against a task nobody has -- so the task is validated first, and a command is held
2087
2101
  // only to a task that stands.
2088
2102
  const published = new Collector();
2089
- validateTaskInto(task, { channel: channel ?? "voice" }, `${path}.task`, published);
2103
+ validateTaskInto(task, { ...taskContext, channel: channel ?? "voice" }, `${path}.task`, published);
2090
2104
  if (published.violations.length > 0 || !isPlainObject(task)) {
2091
2105
  into.add("command.task", `${path}.task`, `the task the command is held to is not one the wire published (${published.violations.map(v => v.rule).join(", ")}): pass the task as the provider sent it, or validate the command's shape alone`);
2092
2106
  return into.violations;
@@ -2158,10 +2172,20 @@ export function validateTaskCommand(command, task, path = "command") {
2158
2172
  offered("endCall");
2159
2173
  handling();
2160
2174
  break;
2161
- case "recording":
2162
- offered("recording");
2163
- handling();
2175
+ case "recording": {
2176
+ if (!offered("recording"))
2177
+ break;
2178
+ const policy = isPlainObject(capabilities.recording) ? capabilities.recording.provider : undefined;
2179
+ const state = isPlainObject(task.recording) ? task.recording.provider : undefined;
2180
+ into.violations.push(...validateRecordingCommandState(command, policy, state, path));
2181
+ if (command.action === "start" || command.action === "resume") {
2182
+ handling();
2183
+ into.require(task.media === "started", "recording.request.phase", path, "capture needs live media");
2184
+ }
2185
+ else
2186
+ inPhase("command.phase.handling", "in-progress", "paused", "completing");
2164
2187
  break;
2188
+ }
2165
2189
  case "connect-back":
2166
2190
  offered("connectBack");
2167
2191
  inPhase("command.phase.completing", "completing");
@@ -2563,3 +2587,257 @@ export function validateAuthenticationState(state, path = "authentication", cont
2563
2587
  }
2564
2588
  return into.violations;
2565
2589
  }
2590
+ // Independent current recording evidence and dispatch validation.
2591
+ const object = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2592
+ const filled = (v) => typeof v === "string" && v.trim().length > 0;
2593
+ function collector() {
2594
+ const violations = [];
2595
+ const check = (ok, rule, path, message) => {
2596
+ ruleEvaluated(`recording.${rule}`);
2597
+ if (!ok)
2598
+ violations.push({ rule: `recording.${rule}`, path, message });
2599
+ return Boolean(ok);
2600
+ };
2601
+ const keys = (v, allowed, path) => {
2602
+ for (const key of Object.keys(v))
2603
+ check(allowed.includes(key), "field", `${path}.${key}`, "field is not permitted in this recording variant");
2604
+ };
2605
+ return { violations, check, keys };
2606
+ }
2607
+ /** Recording observations use canonical UTC millisecond instants, never native capture boundaries. */
2608
+ function instant(v) {
2609
+ if (typeof v !== "string" || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(v))
2610
+ return false;
2611
+ const n = Date.parse(v);
2612
+ return Number.isFinite(n) && new Date(n).toISOString() === v;
2613
+ }
2614
+ export function validateRecordingState(value, path = "recording") {
2615
+ const { violations, check, keys } = collector();
2616
+ if (!object(value)) {
2617
+ check(false, "state.shape", path, "expected recording state");
2618
+ return violations;
2619
+ }
2620
+ if (value.status === "unknown") {
2621
+ keys(value, ["status"], path);
2622
+ return violations;
2623
+ }
2624
+ check(["inactive", "active", "paused"].includes(value.status), "state.status", `${path}.status`, "expected unknown, inactive, active or paused");
2625
+ keys(value, ["status", "observationId", "observedAt", "validUntil", ...(value.status === "inactive" ? [] : ["recordingId"])], path);
2626
+ check(filled(value.observationId), "observationId", `${path}.observationId`, "a confirming observation has an opaque identity");
2627
+ check(instant(value.observedAt), "observedAt", `${path}.observedAt`, "expected canonical UTC millisecond observation instant");
2628
+ check(instant(value.validUntil), "validUntil", `${path}.validUntil`, "expected canonical UTC millisecond expiry instant");
2629
+ if (instant(value.observedAt) && instant(value.validUntil))
2630
+ check(value.observedAt < value.validUntil, "expiry", path, "expiry must be strictly after observation");
2631
+ if (value.status !== "inactive")
2632
+ check(filled(value.recordingId), "recordingId", `${path}.recordingId`, "active and paused states identify the actual recording");
2633
+ return violations;
2634
+ }
2635
+ /** Supply a trusted current instant in the observer's clock domain. Undefined means clock unknown. */
2636
+ export function effectiveRecordingState(state, now) {
2637
+ if (state === undefined || validateRecordingState(state).length || state.status === "unknown" || now === undefined || !Number.isFinite(now))
2638
+ return { status: "unknown" };
2639
+ return Date.parse(state.observedAt) <= now && now < Date.parse(state.validUntil) ? state : { status: "unknown" };
2640
+ }
2641
+ export function validateRecordingPolicy(value, path = "capabilities.recording") {
2642
+ const { violations, check, keys } = collector();
2643
+ if (!object(value)) {
2644
+ check(false, "policy.shape", path, "expected per-task provider/host action policy; true is no longer supported");
2645
+ return violations;
2646
+ }
2647
+ keys(value, ["provider", "host"], path);
2648
+ check(value.provider !== undefined || value.host !== undefined, "policy.empty", path, "declare at least one recording path");
2649
+ for (const source of ["provider", "host"]) {
2650
+ const policy = value[source];
2651
+ if (policy === undefined)
2652
+ continue;
2653
+ const at = `${path}.${source}`;
2654
+ if (!object(policy)) {
2655
+ check(false, "policy.shape", at, "expected permitted actions");
2656
+ continue;
2657
+ }
2658
+ keys(policy, [...RECORDING_ACTIONS, ...(source === "host" ? ["destinationId"] : [])], at);
2659
+ check(RECORDING_ACTIONS.some(action => policy[action] !== undefined), "policy.empty", at, "omit paths offering no actions");
2660
+ for (const action of RECORDING_ACTIONS) {
2661
+ if (policy[action] === undefined)
2662
+ continue;
2663
+ check(policy[action] === true, "policy.action", `${at}.${action}`, "permission is true or absent");
2664
+ }
2665
+ if (source === "host")
2666
+ check(filled(policy.destinationId), "destination", `${at}.destinationId`, "host storage destination must be explicit per task");
2667
+ }
2668
+ return violations;
2669
+ }
2670
+ export function validateHostRecording(value, softphone, path = "host.recording") {
2671
+ if (value === undefined)
2672
+ return [];
2673
+ const { violations, check, keys } = collector();
2674
+ check(softphone, "host.channel", path, "initial host recording requires voice softphone media");
2675
+ if (!object(value)) {
2676
+ check(false, "host.shape", path, "expected host recording declaration and executor");
2677
+ return violations;
2678
+ }
2679
+ keys(value, ["actions", "destinationIds", "execute", "announcesToCaller"], path);
2680
+ if (value.announcesToCaller !== undefined)
2681
+ check(value.announcesToCaller === true, "host.announcesToCaller", `${path}.announcesToCaller`, "caller announcement guarantee is true or absent");
2682
+ for (const [key, allowed] of [["actions", RECORDING_ACTIONS], ["destinationIds", undefined]]) {
2683
+ const list = value[key];
2684
+ if (!Array.isArray(list)) {
2685
+ check(false, "host.list", `${path}.${key}`, "expected explicit array");
2686
+ continue;
2687
+ }
2688
+ check(new Set(list).size === list.length, "host.duplicate", `${path}.${key}`, "duplicate declaration");
2689
+ check(list.every(item => filled(item) && (allowed === undefined || allowed.includes(item))), "host.list", `${path}.${key}`, "unsupported or empty declaration");
2690
+ check(list.length > 0, "host.empty", `${path}.${key}`, "declare at least one supported value");
2691
+ }
2692
+ if (Array.isArray(value.actions)) {
2693
+ check(value.actions.includes("pause") === value.actions.includes("resume"), "host.pause", path, "a pausable host supports resume; task permissions may independently restrict it");
2694
+ }
2695
+ check(typeof value.execute === "function", "host.execute", `${path}.execute`, "host recording requires its own executor");
2696
+ return violations;
2697
+ }
2698
+ export function validateHostRecordings(value, path = "host.recordings") {
2699
+ if (value === undefined)
2700
+ return [];
2701
+ const { violations, check, keys } = collector();
2702
+ if (!Array.isArray(value)) {
2703
+ check(false, "reports.shape", path, "expected full host recording report array");
2704
+ return violations;
2705
+ }
2706
+ const seen = new Set();
2707
+ value.forEach((report, i) => {
2708
+ const at = `${path}[${i}]`;
2709
+ if (!object(report)) {
2710
+ check(false, "report.shape", at, "expected scoped report");
2711
+ return;
2712
+ }
2713
+ keys(report, ["taskId", "allocationId", "state"], at);
2714
+ check(filled(report.taskId) && filled(report.allocationId), "report.scope", at, "report identifies task and allocation");
2715
+ const key = JSON.stringify([report.taskId, report.allocationId]);
2716
+ check(!seen.has(key), "report.duplicate", at, "one host state per task allocation");
2717
+ seen.add(key);
2718
+ violations.push(...validateRecordingState(report.state, `${at}.state`));
2719
+ });
2720
+ return violations;
2721
+ }
2722
+ export function validateRecordingCommandShape(value, source, path = "command") {
2723
+ const { violations, check, keys } = collector();
2724
+ if (!object(value)) {
2725
+ check(false, "command.shape", path, "expected recording command");
2726
+ return violations;
2727
+ }
2728
+ keys(value, ["type", "source", "requestId", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"])], path);
2729
+ check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
2730
+ check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
2731
+ for (const key of ["requestId", "observationId", ...(value.action === "start" ? [] : ["recordingId"])])
2732
+ check(filled(value[key]), "command.identity", `${path}.${key}`, "expected nonempty scoped identity");
2733
+ return violations;
2734
+ }
2735
+ /** One transition table shared by dispatch checks and outcome checks. */
2736
+ const RECORDING_TRANSITIONS = {
2737
+ start: { from: ["inactive"], to: "active" },
2738
+ pause: { from: ["active"], to: "paused" },
2739
+ resume: { from: ["paused"], to: "active" },
2740
+ stop: { from: ["active", "paused"], to: "inactive" },
2741
+ cancel: { from: ["active", "paused"], to: "inactive" },
2742
+ };
2743
+ function recordingTransition(action) {
2744
+ return typeof action === "string" && Object.hasOwn(RECORDING_TRANSITIONS, action)
2745
+ ? RECORDING_TRANSITIONS[action] : undefined;
2746
+ }
2747
+ /** Structural/state permission check. Dispatch MUST also call validateRecordingRequest for scope and freshness. */
2748
+ export function validateRecordingCommandState(command, policy, state, path = "command") {
2749
+ const { violations, check } = collector();
2750
+ if (!object(command)) {
2751
+ check(false, "command.shape", path, "expected recording command");
2752
+ return violations;
2753
+ }
2754
+ const transition = recordingTransition(command.action);
2755
+ if (transition === undefined) {
2756
+ check(false, "command.action", `${path}.action`, "unsupported recording action");
2757
+ return violations;
2758
+ }
2759
+ const permitted = object(policy) && policy[command.action] === true;
2760
+ check(permitted, "command.permission", path, "task does not permit this action on this recorder");
2761
+ if (!object(state) || validateRecordingState(state).length) {
2762
+ check(false, "command.state", path, "no validated current recorder observation");
2763
+ return violations;
2764
+ }
2765
+ check(transition.from.includes(state.status), "command.state", path, "action is invalid in the observed recorder state");
2766
+ check(command.observationId === state.observationId, "command.stale", path, "observation changed; reconcile before acting");
2767
+ if (command.action !== "start")
2768
+ check(command.recordingId === state.recordingId, "command.recordingId", path, "command targets another recording");
2769
+ return violations;
2770
+ }
2771
+ /** Validate immediately before dispatch using current task, host report and trusted observer-domain time. */
2772
+ export function validateRecordingRequest(request, task, context, path = "request") {
2773
+ const { violations, check, keys } = collector();
2774
+ if (!object(request) || !object(task)) {
2775
+ check(false, "request.shape", path, "request and current task required");
2776
+ return violations;
2777
+ }
2778
+ violations.push(...validateTask(task, { ...context.taskContext, channel: "voice" }, `${path}.task`));
2779
+ keys(request, ["taskId", "allocationId", "command"], path);
2780
+ check(filled(request.taskId) && filled(request.allocationId) && request.taskId === task.id && request.allocationId === task.allocationId, "request.scope", path, "task allocation changed or scope missing");
2781
+ check(task.channel === "voice", "request.channel", path, "recording is voice-only");
2782
+ violations.push(...validateRecordingCommandShape(request.command, context.source, `${path}.command`));
2783
+ if (!object(request.command))
2784
+ return violations;
2785
+ const command = request.command;
2786
+ const caps = object(task.capabilities) ? task.capabilities.recording : undefined;
2787
+ violations.push(...validateRecordingPolicy(caps)); // a locked or missing capability cannot dispatch
2788
+ const policy = object(caps) ? caps[context.source] : undefined;
2789
+ let state = object(task.recording) ? task.recording.provider : undefined;
2790
+ if (context.source === "host") {
2791
+ violations.push(...validateHostRecording(context.host, context.softphone === true));
2792
+ check(object(context.host), "host.required", path, "host did not declare recording support");
2793
+ const reports = object(context.hostReport) ? context.hostReport.recordings : undefined;
2794
+ violations.push(...validateHostRecordings(reports));
2795
+ state = Array.isArray(reports) ? reports.find(r => object(r) && r.taskId === task.id && r.allocationId === task.allocationId)?.state : undefined;
2796
+ if (object(context.host)) {
2797
+ check(Array.isArray(context.host.actions) && context.host.actions.includes(command.action), "host.action", path, "host does not support action");
2798
+ check(object(policy) && Array.isArray(context.host.destinationIds) && context.host.destinationIds.includes(policy.destinationId), "host.destination", path, "task destination is not provisioned by this host");
2799
+ }
2800
+ }
2801
+ const fresh = effectiveRecordingState(state, context.now);
2802
+ check(fresh.status !== "unknown", "request.freshness", path, "current state or trusted observer time unavailable/expired");
2803
+ violations.push(...validateRecordingCommandState(command, policy, fresh, `${path}.command`));
2804
+ const opening = command.action === "start" || command.action === "resume";
2805
+ check(opening ? ["in-progress", "paused"].includes(task.phase) && task.media === "started" : ["in-progress", "paused", "completing"].includes(task.phase), "request.phase", path, "capture needs live task media; terminal controls may finish recordings during wrap-up");
2806
+ return violations;
2807
+ }
2808
+ /** Check recording-specific result semantics against the owner's confirming observations. */
2809
+ export function validateRecordingOutcome(command, before, after, result, path = "recordingOutcome") {
2810
+ const { violations, check } = collector();
2811
+ violations.push(...validateRecordingState(before, `${path}.before`), ...validateRecordingState(after, `${path}.after`));
2812
+ if (!object(command) || !object(before) || !object(after)) {
2813
+ check(false, "outcome.shape", path, "command and both observations required");
2814
+ return violations;
2815
+ }
2816
+ violations.push(...validateRecordingCommandShape(command, command.source === "host" ? "host" : "provider", `${path}.command`));
2817
+ // A rejected promise has no result: any independently evidenced state may follow it.
2818
+ if (result === undefined)
2819
+ return violations;
2820
+ violations.push(...validateResult(result, "execute", `${path}.result`));
2821
+ if (!object(result))
2822
+ return violations;
2823
+ check(result.status === "applied" || result.status === "failed", "outcome.result", path, "recording never returns a dialling result");
2824
+ if (result.status === "failed") {
2825
+ 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");
2826
+ }
2827
+ else if (result.status === "applied") {
2828
+ const transition = recordingTransition(command.action);
2829
+ if (transition === undefined)
2830
+ return violations; // shape validation already reported the unknown action
2831
+ check(after.status === transition.to, "outcome.state", path, "applied requires the confirming requested state");
2832
+ check(after.observationId !== before.observationId, "outcome.observation", path, "an applied transition needs new confirming evidence");
2833
+ check(before.observationId === command.observationId, "outcome.stale", path, "command must act against its named observation");
2834
+ check(transition.from.includes(before.status), "outcome.origin", path, "applied cannot claim an impossible transition");
2835
+ if (command.action !== "start")
2836
+ check(command.recordingId === before.recordingId, "outcome.recordingId", path, "command must target the prior recording");
2837
+ if (command.action === "pause" || command.action === "resume")
2838
+ check(after.recordingId === before.recordingId, "outcome.continuity", path, "pause/resume preserves recording identity");
2839
+ if (instant(before.observedAt) && instant(after.observedAt))
2840
+ check(after.observedAt >= before.observedAt, "outcome.order", path, "clock discontinuity requires reconciliation, not a reversed confirmation");
2841
+ }
2842
+ return violations;
2843
+ }
package/guide.md CHANGED
@@ -58,7 +58,7 @@ transport, a task, a break and a call each have a word of their own:
58
58
 
59
59
  ### `OMNI_PROTOCOL_VERSION`
60
60
 
61
- The exact protocol version implemented by this package. The current value is `1`.
61
+ 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.
62
62
 
63
63
  ### `Manifest.supportedProtocolVersions`
64
64
 
@@ -289,7 +289,55 @@ type HostAudioOutput =
289
289
 
290
290
  type UrlVisibility = "full" | "domain" | "hidden";
291
291
 
292
+ type RecordingSource = "provider" | "host";
293
+ type RecordingAction = "start" | "pause" | "resume" | "stop" | "cancel";
294
+ const RECORDING_ACTIONS = ["start", "pause", "resume", "stop", "cancel"] as const satisfies readonly RecordingAction[];
295
+ interface RecordingActions {
296
+ start?: true;
297
+ pause?: true;
298
+ resume?: true;
299
+ stop?: true;
300
+ cancel?: true;
301
+ }
302
+ interface TaskRecordingPolicy {
303
+ provider?: RecordingActions;
304
+ host?: RecordingActions & { destinationId: string };
305
+ }
306
+ type RecordingState =
307
+ | { status: "unknown"; observationId?: never; observedAt?: never; validUntil?: never; recordingId?: never }
308
+ | ({ observationId: string; observedAt: IsoTimestamp; validUntil: IsoTimestamp } & (
309
+ | { status: "inactive"; recordingId?: never }
310
+ | { status: "active" | "paused"; recordingId: string }
311
+ ));
312
+ type RecordingCommand = {
313
+ type: "recording";
314
+ source: RecordingSource;
315
+ requestId: string;
316
+ observationId: string;
317
+ } & (
318
+ | { action: "start"; recordingId?: never }
319
+ | { action: "pause" | "resume" | "stop" | "cancel"; recordingId: string }
320
+ );
321
+ interface HostRecordingReport {
322
+ taskId: TaskId;
323
+ allocationId: AllocationId;
324
+ state: RecordingState;
325
+ }
326
+ interface HostRecording {
327
+ announcesToCaller?: true;
328
+ actions: RecordingAction[];
329
+ destinationIds: string[];
330
+ execute(request: HostRecordingRequest): Promise<RecordingCommandResult>;
331
+ }
332
+ type RecordingCommandResult = Exclude<TaskCommandResult, { status: "dialling" }>;
333
+ interface HostRecordingRequest {
334
+ taskId: TaskId;
335
+ allocationId: AllocationId;
336
+ command: RecordingCommand & { source: "host" };
337
+ }
338
+
292
339
  type HostReport = {
340
+ recordings?: HostRecordingReport[];
293
341
  online: boolean;
294
342
  audio?: {
295
343
  input: HostAudioInput;
@@ -303,6 +351,7 @@ type HostGuarantees = {
303
351
  };
304
352
 
305
353
  type Host = {
354
+ recording?: HostRecording;
306
355
  guarantees: HostGuarantees;
307
356
  mute?: HostMute;
308
357
  report(): HostReport;
@@ -327,7 +376,7 @@ type ConnectContext = {
327
376
  log?: (entry: unknown) => void;
328
377
  } & (
329
378
  | { phone: "softphone"; host: { mute: HostMute } }
330
- | { phone?: "deskPhone"; host: { mute?: never } }
379
+ | { phone?: "deskPhone"; host: { mute?: never; recording?: never } }
331
380
  );
332
381
 
333
382
  type TransportStatus = "connecting" | "active" | "error";
@@ -511,7 +560,7 @@ type TaskCapabilities<C extends Channel = Channel> =
511
560
  warmTransfer?: Lockable<DestinationDirectory>;
512
561
  leadAssist?: Lockable<true>;
513
562
  conference?: Lockable<DestinationDirectory>;
514
- recording?: Lockable<true>;
563
+ recording?: Lockable<TaskRecordingPolicy>;
515
564
  }
516
565
  : C extends "chat"
517
566
  ? SharedTaskCapabilities & { decline?: Lockable<true>; hold?: Lockable<true> }
@@ -686,8 +735,8 @@ type Task<C extends Channel = Channel> = {
686
735
  handlingHistory?: TaskHandlingHistory;
687
736
  } & TaskCompletion & (
688
737
  C extends "voice"
689
- ? { onCall?: OnCall[]; leadAssist?: TaskLeadAssist; assisting?: TaskAssisting; monitoring?: TaskMonitoring; media?: TaskMediaState }
690
- : { onCall?: never; leadAssist?: never; assisting?: never; monitoring?: never; media?: never }
738
+ ? { recording?: { provider?: RecordingState }; onCall?: OnCall[]; leadAssist?: TaskLeadAssist; assisting?: TaskAssisting; monitoring?: TaskMonitoring; media?: TaskMediaState }
739
+ : { recording?: never; onCall?: never; leadAssist?: never; assisting?: never; monitoring?: never; media?: never }
691
740
  );
692
741
 
693
742
  type PreviewDeadline = "calls" | "expires";
@@ -753,7 +802,7 @@ type VoiceTaskCommand =
753
802
  | { type: "conference"; action: "add"; dialId: DialId; destinationId: string }
754
803
  | { type: "conference"; action: "remove"; destinationId: string; party?: never }
755
804
  | { type: "conference"; action: "remove"; party: true; destinationId?: never }
756
- | { type: "recording"; action: "start" | "pause" | "resume" | "stop" }
805
+ | (RecordingCommand & { source: "provider" })
757
806
  | ({ type: "complete" } & DispositionPayload);
758
807
 
759
808
  type ChatTaskCommand =
@@ -2994,7 +3043,7 @@ See **Which commands need a capability**.
2994
3043
  | `warmTransfer` | Secondary menu item: Warm transfer | Omni may park the customer and call a destination first, then hand the customer over or cancel back. See **Warm transfer**. |
2995
3044
  | `leadAssist` | Secondary menu item: Lead assist | Omni may ask a lead to join this call, with a note. The lead's decision reaches the agent on `Task.leadAssist`. See **Lead assist**. |
2996
3045
  | `conference` | Secondary button: Conference | Omni may dial a destination into the active call, and remove one person from it -- a conferenced entry, one still ringing included, which calls the dial off, or the party, leaving the agent with the colleague. See **Ending a call, and removing one person from it**. |
2997
- | `recording` | Overflow menu item: Recording | Omni may expose start, pause, resume, and stop recording controls. |
3046
+ | `recording` | Overflow menu item: Recording | Per-task provider and host policies expose only their permitted recording actions; each has independent state and routing. |
2998
3047
  | `dispositions` | Primary button: Complete | Omni may request task disposal with a provider disposition and notes. |
2999
3048
 
3000
3049
  ### Publishing codes and destinations
@@ -4216,7 +4265,7 @@ capability's `notes` setting. A task publishing no codes still receives `complet
4216
4265
 
4217
4266
  Every command reaches the provider through `execute`, with no branch at the call site, and every
4218
4267
  command asks the provider to **perform** something: `hold`, `transfer`, `conference`, `end-call`,
4219
- `recording` and the rest act on the platform's own call leg, its bridge, or its record of the
4268
+ Provider-targeted `recording` commands and the rest act on the platform's own call leg, its bridge, or its record of the
4220
4269
  task. Nothing has happened until the provider applies them, and `failed` means nothing happened.
4221
4270
 
4222
4271
  **The provider performs every action; the host only offers it.** A control drawn on the host is
@@ -4325,7 +4374,7 @@ declared:
4325
4374
  | Everything else | Its own named capability. |
4326
4375
 
4327
4376
  **A control on the contact belongs to the handling phases**, `in-progress` and `paused`:
4328
- `hold`, `resume` and `pause`, `end-call`, `recording`, every `transfer` and `conference` action, and
4377
+ `hold`, `resume` and `pause`, `end-call`, every `transfer` and `conference` action, and
4329
4378
  every `lead-assist` action. Each acts on the call or the conversation, and only while there is one.
4330
4379
  Before `in-progress` nothing has been placed or opened; in `completing` the handling has ended -- a
4331
4380
  call with nobody on it, a conversation closed -- and a wrap-up that still shows Transfer shows it
@@ -4940,3 +4989,146 @@ belongs in this contract is the boundary. A provider says what a control **is**
4940
4989
  capabilities and what its work is **called** through `phaseLabels` and `taskTypePresentation`; how
4941
4990
  any of it is drawn is Omni's. A task cannot select a design language, inject a component, or
4942
4991
  override the agent's theme and font preferences.
4992
+
4993
+ ## Independent task recording
4994
+
4995
+ Recording is voice-only. A task can arrive already recording, including while pending, and offer
4996
+ no recording controls. The provider publishes only its own current state on
4997
+ the provider field of the task’s `recording` state. The host publishes its own full scoped view in `HostReport.recordings`.
4998
+ A provider task update cannot replace host state. Neither a capability nor an accepted command
4999
+ establishes recording. Missing state, lost recorder observation, transport loss and expired
5000
+ observation mean unknown; they never mean stopped. Paused means a recording remains open without
5001
+ capturing. Task hold, task pause and recording pause are independent.
5002
+
5003
+ The per-task policy is `Task.capabilities.recording`, with independently optional provider and host
5004
+ action sets. The outer capability may be locked. Absence grants no permission, and a state may
5005
+ exist without any permission. A provider may offer only stop for a recording started automatically,
5006
+ or withdraw a control on a later task update. There is no global recording mode and no automatic
5007
+ start from a capability declaration. Host support is declared on `ConnectContext.host.recording`,
5008
+ not the provider-owned manifest. The task chooses an explicitly provisioned host destination;
5009
+ unknown destinations or unsupported actions are refused visibly, never redirected to provider
5010
+ recording or a default upload location. Initial host support requires voice softphone media and
5011
+ capture of both local and remote audio; a host unable to capture either must refuse start/resume.
5012
+ This package declares that contract; it supplies no recorder, media mixing, storage or upload.
5013
+
5014
+ | Action | Required current recording state | Confirmed outcome |
5015
+ | --- | --- | --- |
5016
+ | start | inactive | active with a new recording identity |
5017
+ | pause | active | paused, preserving recording identity and captured audio |
5018
+ | resume | paused | active with the same recording identity |
5019
+ | stop | active or paused | inactive; finish and retain captured audio |
5020
+ | cancel | active or paused | inactive; abandon and discard captured audio |
5021
+
5022
+ Stop finishes the recording and retains captured audio. Cancel abandons the recording and discards
5023
+ its captured audio; the task policy offers it with `cancel: true`. There is no configurable cancel
5024
+ effect. A recorder that cannot confirm disposal must not offer Cancel; it can offer Stop instead.
5025
+ Cancel never means cancelling an in-flight start request or deleting arbitrary past recordings.
5026
+ Stop can be applied only after finalization/retention succeeds; Cancel only after both cessation
5027
+ and disposal of this recording's audio succeed under the recorder's storage contract.
5028
+ Partial success (capture stopped but storage outcome unknown) cannot return failed with a claim
5029
+ of no effect. It rejects with unknown outcome, reports the failure visibly and publishes whatever
5030
+ current capture state is actually known. Retention is not a promise of sample-perfect audio.
5031
+
5032
+ Provider commands go exclusively to `Connection.execute`; host commands go exclusively to
5033
+ `HostRecording.execute`. Both include task, allocation, request and observation identities; all
5034
+ non-start commands identify the particular recording. IDs are opaque and scoped by provider login,
5035
+ task and recorder owner. They are never inferred from filenames or current agent identity. A
5036
+ recording ID survives pause/resume and reallocation only where the same recorder confirms continuity;
5037
+ commands always name the current allocation. A later start gets a different ID. Reconnect does not
5038
+ create a new recording or fresh evidence. After lost continuity, use unknown until reconciled.
5039
+ There is no automatic restart, transfer to another recorder, or stop on task hold/disconnect.
5040
+ Task removal does not prove recording stopped: outstanding host recorders remain tracked by the
5041
+ host until its executor reconciles/finishes them, with visible unresolved cleanup failures.
5042
+
5043
+ Only start/resume require an in-progress or paused task with started media. Pause/stop/cancel may
5044
+ also finish an independently observed recorder while the task is completing. A pending task may
5045
+ show active recording, but agent controls wait until handling begins. The two recording paths may
5046
+ both be active. A command to either path has no implied effect on the other.
5047
+
5048
+ Each confirmed observation has a fresh opaque observation identity, a canonical UTC millisecond
5049
+ observation instant and an exclusive expiry. These times describe current evidence, never historical
5050
+ capture boundaries. A trusted observer-domain current time must satisfy observedAt <= now < validUntil.
5051
+ Use `effectiveRecordingState` with that explicitly trusted time; an unavailable clock yields unknown.
5052
+ Receipt, replay and task publication never extend freshness. Clock discontinuity invalidates evidence;
5053
+ consumers must invalidate their clock estimate and use monotonic aging so clock rollback cannot
5054
+ revive expired evidence. The executor compares observation identity and recording identity against
5055
+ its latest state atomically before I/O. An intervening observation or allocation change refuses the
5056
+ stale command; it never acts on a replacement recorder. Providers lacking trustworthy current-state
5057
+ identity or observation time publish unknown and offer no issuable controls.
5058
+
5059
+ Use `validateTask` and `validateTaskCommand` for published shape and static permissions.
5060
+ Immediately before dispatch, additionally use `validateRecordingRequest` against the full current
5061
+ task and explicit observer-domain time. For host commands also supply the current host declaration,
5062
+ full host report and softphone context. The same checks must run at the executing boundary;
5063
+ client validation alone is not authorization. The executor serializes operations on each recorder
5064
+ and durably correlates request identity with scope, action and outcome: an exact retry refers to the
5065
+ same operation; reuse with different contents is refused. It never silently retries an ambiguous
5066
+ operation. Implementations without reliable request correlation must refuse controls.
5067
+
5068
+ Keep command progress in host UI separately from authoritative state. Applied means the requested
5069
+ transition and disposition actually completed; publish the confirming task or host report before
5070
+ resolving applied. Failed means confirmed no effect. Rejected promise means outcome unknown:
5071
+ show the failure, reconcile that path and do not automatically retry or pretend inactive. Authoritative
5072
+ updates remain full current task/host views, not replayed provider events. These current-state fields
5073
+ create no handling-history entries and infer no actors, durations or historical capture boundaries.
5074
+
5075
+ Migration is explicit: replace the old true recording capability with per-path action policies,
5076
+ replace untargeted commands with scoped commands, and retain protocol version 1. During pre-release
5077
+ development, hosts and adapters must align their recording contracts; negotiation does not detect
5078
+ differences between package revisions that share that protocol number. No legacy-shape fallback is provided.
5079
+ This change does not publish a package or enable recording in any existing host/provider by itself.
5080
+
5081
+ `validateRecordingOutcome` checks confirming observations against recording-specific applied/failed
5082
+ semantics, including pause/resume identity and rejection of a dialling result. It cannot prove audio
5083
+ retention/disposal or source truth from a status flag; those remain executor obligations.
5084
+
5085
+ Host destination selection binds storage when start actually creates a recording. Existing recordings
5086
+ keep that binding through pause/resume/stop/cancel; a later policy cannot redirect their stored audio.
5087
+ The executor rejects a mismatched destination rather than moving or discarding another binding.
5088
+ Action permission does not authorize unattended invocation: host controls require the agent's explicit
5089
+ act, and provider authorization remains enforced at its authenticated command boundary.
5090
+
5091
+
5092
+ Recording dispatch must receive the same known task-validation context as the published task.
5093
+ Pass organisation levels and other known restrictions through the optional fourth argument of
5094
+ `validateTaskCommand`, and through `taskContext` in `validateRecordingRequest`. This keeps a valid
5095
+ custom organisation lock from being rejected against the default ladder, and prevents known
5096
+ capability restrictions from disappearing at dispatch. The standalone `validateRecordingCommandState`
5097
+ requires an affirmative permission; false, malformed permission declarations and unknown actions grant nothing.
5098
+
5099
+
5100
+ ### Host recording announcements to the caller
5101
+
5102
+ `HostRecording.announcesToCaller`, when true, is a guarantee that the host delivers audible recording
5103
+ status messages to the remote party over the call's outgoing audio. It belongs inside the optional
5104
+ host recording capability: without that capability there is no such guarantee. Omission makes no
5105
+ promise; false is invalid. It does not belong in the provider manifest, the task's recording policy
5106
+ or the general `HostGuarantees` object. A provider can inspect this host guarantee when deciding
5107
+ whether to permit host recording on a task.
5108
+
5109
+ The guarantee applies only to host-owned recordings. Providers implement announcements for their
5110
+ own recordings at their end; provider state updates must not cause the host to announce those
5111
+ recordings. Both recorders can operate independently on the same call.
5112
+
5113
+ On a confirmed host start, the remote party hears that recording started. On a confirmed stop or
5114
+ cancel, they hear that recording stopped. If pause/resume is supported, say paused/resumed so a
5115
+ pause is not presented as a finished recording. Announcements follow actual capture transitions,
5116
+ not button clicks, accepted requests or task-policy changes. When attaching to an already-active
5117
+ host recording, announce that recording is active, without inventing its original start time.
5118
+ Repeated observations of the same state do not repeat announcements. Unknown or expired evidence
5119
+ must never produce a stopped announcement.
5120
+
5121
+ A local sound, UI indicator, screen-reader message to the agent, or sound leaking through the
5122
+ microphone does not fulfil this guarantee. The host must deliver the message directly in the audio
5123
+ sent to the remote party, even when the agent microphone is muted. It may also notify the agent.
5124
+ Only declare the guarantee when the host can provide that outgoing audio path. An announcement
5125
+ that cannot be delivered, including after the remote party has disconnected, is a visible failure;
5126
+ never claim delivery or replay the notice into a different call.
5127
+
5128
+ For a command under this guarantee, applied requires both the recording action and its announcement
5129
+ to succeed. If capture changes but announcement delivery fails, publish the actual recording state,
5130
+ report the announcement failure visibly and reject with unknown command outcome. Do not claim no
5131
+ effect, automatically repeat the recording action or silently switch to an agent-only notice.
5132
+
5133
+ `validateHostRecording` checks the declaration, including its true-or-absent guarantee. Actual audio
5134
+ delivery remains the host implementation's responsibility and must be exercised in its own tests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.80",
3
+ "version": "0.1.82",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",