@xema/omni-protocol 0.1.80 → 0.1.81

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
@@ -1,5 +1,5 @@
1
1
  /** The protocol version implemented by this package. */
2
- export declare const OMNI_PROTOCOL_VERSION: 1;
2
+ export declare const OMNI_PROTOCOL_VERSION: 2;
3
3
  /** Every version this package can interoperate with. */
4
4
  export declare const OMNI_SUPPORTED_PROTOCOL_VERSIONS: readonly number[];
5
5
  /**
@@ -427,7 +427,93 @@ 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
+ /** Cancel's captured-audio disposition is explicit; stop always retains captured audio. */
435
+ export type RecordingCancelEffect = "retain" | "discard";
436
+ export interface RecordingActions {
437
+ start?: true;
438
+ pause?: true;
439
+ resume?: true;
440
+ stop?: true;
441
+ cancel?: {
442
+ effect: RecordingCancelEffect;
443
+ };
444
+ }
445
+ /** Per-task permission, not a provider-wide recording switch. Absence grants nothing. */
446
+ export interface TaskRecordingPolicy {
447
+ provider?: RecordingActions;
448
+ host?: RecordingActions & {
449
+ destinationId: string;
450
+ };
451
+ }
452
+ /** Current evidence only. Expiry/disconnect means unknown, never a historical stop. */
453
+ export type RecordingState = {
454
+ status: "unknown";
455
+ observationId?: never;
456
+ observedAt?: never;
457
+ validUntil?: never;
458
+ recordingId?: never;
459
+ } | ({
460
+ observationId: string;
461
+ observedAt: IsoTimestamp;
462
+ validUntil: IsoTimestamp;
463
+ } & ({
464
+ status: "inactive";
465
+ recordingId?: never;
466
+ } | {
467
+ status: "active" | "paused";
468
+ recordingId: string;
469
+ }));
470
+ /** Opaque IDs are scoped by login, task and recorder owner; preserved across pause/resume. */
471
+ export type RecordingCommand = {
472
+ type: "recording";
473
+ source: RecordingSource;
474
+ requestId: string;
475
+ /** Compare-and-set against current evidence; never act on a replacement recording. */
476
+ observationId: string;
477
+ } & ({
478
+ action: "start";
479
+ recordingId?: never;
480
+ cancelEffect?: never;
481
+ } | {
482
+ action: "pause" | "resume" | "stop";
483
+ recordingId: string;
484
+ cancelEffect?: never;
485
+ } | {
486
+ action: "cancel";
487
+ recordingId: string;
488
+ cancelEffect: RecordingCancelEffect;
489
+ });
490
+ export interface HostRecordingReport {
491
+ taskId: TaskId;
492
+ allocationId: AllocationId;
493
+ state: RecordingState;
494
+ }
495
+ /** Host declaration in ConnectContext, not the provider-owned Manifest. */
496
+ export interface HostRecording {
497
+ actions: RecordingAction[];
498
+ cancelEffects: RecordingCancelEffect[];
499
+ /** Explicitly provisioned destinations. A task chooses one; no upload/storage fallback. */
500
+ destinationIds: string[];
501
+ execute(request: HostRecordingRequest): Promise<RecordingCommandResult>;
502
+ }
503
+ /** Recording applies or fails; it never dials. Rejection without result means unknown. */
504
+ export type RecordingCommandResult = Exclude<TaskCommandResult, {
505
+ status: "dialling";
506
+ }>;
507
+ export interface HostRecordingRequest {
508
+ taskId: TaskId;
509
+ allocationId: AllocationId;
510
+ command: RecordingCommand & {
511
+ source: "host";
512
+ };
513
+ }
430
514
  export interface HostReport {
515
+ /** Full current host-owned recording view; never provider-owned state. */
516
+ recordings?: HostRecordingReport[];
431
517
  /** 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
518
  online: boolean;
433
519
  audio?: {
@@ -449,6 +535,8 @@ export interface HostGuarantees {
449
535
  personConsent?: true;
450
536
  }
451
537
  export interface Host {
538
+ /** Voice softphone recording support; policy and destination remain per task. */
539
+ recording?: HostRecording;
452
540
  guarantees: HostGuarantees;
453
541
  /** 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
542
  mute?: HostMute;
@@ -494,6 +582,7 @@ export type ConnectContext = {
494
582
  phone?: "deskPhone";
495
583
  host: {
496
584
  mute?: never;
585
+ recording?: never;
497
586
  };
498
587
  });
499
588
  export type TransportStatus = "connecting" | "active" | "error";
@@ -581,7 +670,7 @@ export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ?
581
670
  /** Ask a lead to join this call, with a note. The lead's decision arrives on `Task.leadAssist`. */
582
671
  leadAssist?: Lockable<true>;
583
672
  conference?: Lockable<DestinationDirectory>;
584
- recording?: Lockable<true>;
673
+ recording?: Lockable<TaskRecordingPolicy>;
585
674
  } : C extends "chat" ? SharedTaskCapabilities & {
586
675
  decline?: Lockable<true>;
587
676
  hold?: Lockable<true>;
@@ -888,12 +977,16 @@ export type Task<C extends Channel = Channel> = {
888
977
  attributes?: TaskAttribute[];
889
978
  handlingHistory?: TaskHandlingHistory;
890
979
  } & TaskCompletion & (C extends "voice" ? {
980
+ recording?: {
981
+ provider?: RecordingState;
982
+ };
891
983
  onCall?: OnCall[];
892
984
  leadAssist?: TaskLeadAssist;
893
985
  assisting?: TaskAssisting;
894
986
  monitoring?: TaskMonitoring;
895
987
  media?: TaskMediaState;
896
988
  } : {
989
+ recording?: never;
897
990
  onCall?: never;
898
991
  leadAssist?: never;
899
992
  assisting?: never;
@@ -1040,10 +1133,9 @@ export type VoiceTaskCommand = {
1040
1133
  action: "remove";
1041
1134
  party: true;
1042
1135
  destinationId?: never;
1043
- } | {
1044
- type: "recording";
1045
- action: "start" | "pause" | "resume" | "stop";
1046
- } | ({
1136
+ } | (RecordingCommand & {
1137
+ source: "provider";
1138
+ }) | ({
1047
1139
  type: "complete";
1048
1140
  } & DispositionPayload);
1049
1141
  export type ChatTaskCommand = {
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Where this file and guide.md disagree, the guide is right and this is a defect.
4
4
  /** The protocol version implemented by this package. */
5
- export const OMNI_PROTOCOL_VERSION = 1;
5
+ export const OMNI_PROTOCOL_VERSION = 2;
6
6
  /** Every version this package can interoperate with. */
7
7
  export const OMNI_SUPPORTED_PROTOCOL_VERSIONS = [OMNI_PROTOCOL_VERSION];
8
8
  /**
@@ -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); },
@@ -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[];
@@ -135,3 +135,22 @@ 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
+ }, path?: string): ProtocolViolation[];
155
+ /** Check recording-specific result semantics against the owner's confirming observations. */
156
+ 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
@@ -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)
@@ -2158,10 +2172,19 @@ export function validateTaskCommand(command, task, path = "command") {
2158
2172
  offered("endCall");
2159
2173
  handling();
2160
2174
  break;
2161
- case "recording":
2175
+ case "recording": {
2162
2176
  offered("recording");
2163
- handling();
2177
+ const policy = isPlainObject(capabilities.recording) ? capabilities.recording.provider : undefined;
2178
+ const state = isPlainObject(task.recording) ? task.recording.provider : undefined;
2179
+ into.violations.push(...validateRecordingCommandState(command, policy, state, path));
2180
+ if (command.action === "start" || command.action === "resume") {
2181
+ handling();
2182
+ into.require(task.media === "started", "recording.request.phase", path, "capture needs live media");
2183
+ }
2184
+ else
2185
+ inPhase("command.phase.handling", "in-progress", "paused", "completing");
2164
2186
  break;
2187
+ }
2165
2188
  case "connect-back":
2166
2189
  offered("connectBack");
2167
2190
  inPhase("command.phase.completing", "completing");
@@ -2563,3 +2586,251 @@ export function validateAuthenticationState(state, path = "authentication", cont
2563
2586
  }
2564
2587
  return into.violations;
2565
2588
  }
2589
+ // Independent current recording evidence and dispatch validation.
2590
+ const object = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2591
+ const filled = (v) => typeof v === "string" && v.trim().length > 0;
2592
+ const effects = ["retain", "discard"];
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
+ if (action === "cancel") {
2664
+ const cancel = policy.cancel;
2665
+ check(object(cancel) && effects.includes(cancel.effect), "cancel.effect", `${at}.cancel`, "cancel explicitly retains or discards this recording's captured audio");
2666
+ if (object(cancel))
2667
+ keys(cancel, ["effect"], `${at}.cancel`);
2668
+ }
2669
+ else
2670
+ check(policy[action] === true, "policy.action", `${at}.${action}`, "permission is true or absent");
2671
+ }
2672
+ if (source === "host")
2673
+ check(filled(policy.destinationId), "destination", `${at}.destinationId`, "host storage destination must be explicit per task");
2674
+ }
2675
+ return violations;
2676
+ }
2677
+ export function validateHostRecording(value, softphone, path = "host.recording") {
2678
+ if (value === undefined)
2679
+ return [];
2680
+ const { violations, check, keys } = collector();
2681
+ check(softphone, "host.channel", path, "initial host recording requires voice softphone media");
2682
+ if (!object(value)) {
2683
+ check(false, "host.shape", path, "expected host recording declaration and executor");
2684
+ return violations;
2685
+ }
2686
+ keys(value, ["actions", "cancelEffects", "destinationIds", "execute"], path);
2687
+ for (const [key, allowed] of [["actions", RECORDING_ACTIONS], ["cancelEffects", effects], ["destinationIds", undefined]]) {
2688
+ const list = value[key];
2689
+ if (!Array.isArray(list)) {
2690
+ check(false, "host.list", `${path}.${key}`, "expected explicit array");
2691
+ continue;
2692
+ }
2693
+ check(new Set(list).size === list.length, "host.duplicate", `${path}.${key}`, "duplicate declaration");
2694
+ check(list.every(item => filled(item) && (allowed === undefined || allowed.includes(item))), "host.list", `${path}.${key}`, "unsupported or empty declaration");
2695
+ if (key !== "cancelEffects")
2696
+ check(list.length > 0, "host.empty", `${path}.${key}`, "declare at least one supported value");
2697
+ }
2698
+ if (Array.isArray(value.actions) && Array.isArray(value.cancelEffects)) {
2699
+ check(value.actions.includes("cancel") === (value.cancelEffects.length > 0), "host.cancel", path, "cancel support and explicit effects must agree");
2700
+ check(value.actions.includes("pause") === value.actions.includes("resume"), "host.pause", path, "a pausable host supports resume; task permissions may independently restrict it");
2701
+ }
2702
+ check(typeof value.execute === "function", "host.execute", `${path}.execute`, "host recording requires its own executor");
2703
+ return violations;
2704
+ }
2705
+ export function validateHostRecordings(value, path = "host.recordings") {
2706
+ if (value === undefined)
2707
+ return [];
2708
+ const { violations, check, keys } = collector();
2709
+ if (!Array.isArray(value)) {
2710
+ check(false, "reports.shape", path, "expected full host recording report array");
2711
+ return violations;
2712
+ }
2713
+ const seen = new Set();
2714
+ value.forEach((report, i) => {
2715
+ const at = `${path}[${i}]`;
2716
+ if (!object(report)) {
2717
+ check(false, "report.shape", at, "expected scoped report");
2718
+ return;
2719
+ }
2720
+ keys(report, ["taskId", "allocationId", "state"], at);
2721
+ check(filled(report.taskId) && filled(report.allocationId), "report.scope", at, "report identifies task and allocation");
2722
+ const key = JSON.stringify([report.taskId, report.allocationId]);
2723
+ check(!seen.has(key), "report.duplicate", at, "one host state per task allocation");
2724
+ seen.add(key);
2725
+ violations.push(...validateRecordingState(report.state, `${at}.state`));
2726
+ });
2727
+ return violations;
2728
+ }
2729
+ export function validateRecordingCommandShape(value, source, path = "command") {
2730
+ const { violations, check, keys } = collector();
2731
+ if (!object(value)) {
2732
+ check(false, "command.shape", path, "expected recording command");
2733
+ return violations;
2734
+ }
2735
+ keys(value, ["type", "source", "requestId", "observationId", "action", ...(value.action === "start" ? [] : ["recordingId"]), ...(value.action === "cancel" ? ["cancelEffect"] : [])], path);
2736
+ check(value.type === "recording" && value.source === source, "command.source", path, `this executor accepts only ${source} recording commands`);
2737
+ check(RECORDING_ACTIONS.includes(value.action), "command.action", `${path}.action`, "unsupported recording action");
2738
+ for (const key of ["requestId", "observationId", ...(value.action === "start" ? [] : ["recordingId"])])
2739
+ check(filled(value[key]), "command.identity", `${path}.${key}`, "expected nonempty scoped identity");
2740
+ if (value.action === "cancel")
2741
+ check(effects.includes(value.cancelEffect), "cancel.effect", `${path}.cancelEffect`, "explicit captured-audio disposition required");
2742
+ return violations;
2743
+ }
2744
+ /** Structural/state permission check. Dispatch MUST also call validateRecordingRequest for scope and freshness. */
2745
+ export function validateRecordingCommandState(command, policy, state, path = "command") {
2746
+ const { violations, check } = collector();
2747
+ if (!object(command))
2748
+ return violations;
2749
+ check(object(policy) && policy[command.action] !== undefined, "command.permission", path, "task does not permit this action on this recorder");
2750
+ if (!object(state) || validateRecordingState(state).length) {
2751
+ check(false, "command.state", path, "no validated current recorder observation");
2752
+ return violations;
2753
+ }
2754
+ const allowed = command.action === "start" ? ["inactive"] : command.action === "pause" ? ["active"] : command.action === "resume" ? ["paused"] : ["active", "paused"];
2755
+ check(allowed.includes(state.status), "command.state", path, "action is invalid in the observed recorder state");
2756
+ check(command.observationId === state.observationId, "command.stale", path, "observation changed; reconcile before acting");
2757
+ if (command.action !== "start")
2758
+ check(command.recordingId === state.recordingId, "command.recordingId", path, "command targets another recording");
2759
+ if (command.action === "cancel" && object(policy))
2760
+ check(object(policy.cancel) && command.cancelEffect === policy.cancel.effect, "command.cancelEffect", path, "cancel disposition must match task policy");
2761
+ return violations;
2762
+ }
2763
+ /** Validate immediately before dispatch using current task, host report and trusted observer-domain time. */
2764
+ export function validateRecordingRequest(request, task, context, path = "request") {
2765
+ const { violations, check, keys } = collector();
2766
+ if (!object(request) || !object(task)) {
2767
+ check(false, "request.shape", path, "request and current task required");
2768
+ return violations;
2769
+ }
2770
+ violations.push(...validateTask(task, { channel: "voice" }, `${path}.task`));
2771
+ keys(request, ["taskId", "allocationId", "command"], path);
2772
+ check(filled(request.taskId) && filled(request.allocationId) && request.taskId === task.id && request.allocationId === task.allocationId, "request.scope", path, "task allocation changed or scope missing");
2773
+ check(task.channel === "voice", "request.channel", path, "recording is voice-only");
2774
+ violations.push(...validateRecordingCommandShape(request.command, context.source, `${path}.command`));
2775
+ if (!object(request.command))
2776
+ return violations;
2777
+ const command = request.command;
2778
+ const caps = object(task.capabilities) ? task.capabilities.recording : undefined;
2779
+ violations.push(...validateRecordingPolicy(caps)); // a locked or missing capability cannot dispatch
2780
+ const policy = object(caps) ? caps[context.source] : undefined;
2781
+ let state = object(task.recording) ? task.recording.provider : undefined;
2782
+ if (context.source === "host") {
2783
+ violations.push(...validateHostRecording(context.host, context.softphone === true));
2784
+ check(object(context.host), "host.required", path, "host did not declare recording support");
2785
+ const reports = object(context.hostReport) ? context.hostReport.recordings : undefined;
2786
+ violations.push(...validateHostRecordings(reports));
2787
+ state = Array.isArray(reports) ? reports.find(r => object(r) && r.taskId === task.id && r.allocationId === task.allocationId)?.state : undefined;
2788
+ if (object(context.host)) {
2789
+ check(Array.isArray(context.host.actions) && context.host.actions.includes(command.action), "host.action", path, "host does not support action");
2790
+ 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");
2791
+ if (command.action === "cancel")
2792
+ check(Array.isArray(context.host.cancelEffects) && context.host.cancelEffects.includes(command.cancelEffect), "host.cancelEffect", path, "host does not support cancel disposition");
2793
+ }
2794
+ }
2795
+ const fresh = effectiveRecordingState(state, context.now);
2796
+ check(fresh.status !== "unknown", "request.freshness", path, "current state or trusted observer time unavailable/expired");
2797
+ violations.push(...validateRecordingCommandState(command, policy, fresh, `${path}.command`));
2798
+ const opening = command.action === "start" || command.action === "resume";
2799
+ 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");
2800
+ return violations;
2801
+ }
2802
+ /** Check recording-specific result semantics against the owner's confirming observations. */
2803
+ export function validateRecordingOutcome(command, before, after, result, path = "recordingOutcome") {
2804
+ const { violations, check } = collector();
2805
+ violations.push(...validateRecordingState(before, `${path}.before`), ...validateRecordingState(after, `${path}.after`));
2806
+ if (!object(command) || !object(before) || !object(after)) {
2807
+ check(false, "outcome.shape", path, "command and both observations required");
2808
+ return violations;
2809
+ }
2810
+ violations.push(...validateRecordingCommandShape(command, command.source === "host" ? "host" : "provider", `${path}.command`));
2811
+ // A rejected promise has no result: any independently evidenced state may follow it.
2812
+ if (result === undefined)
2813
+ return violations;
2814
+ violations.push(...validateResult(result, "execute", `${path}.result`));
2815
+ if (!object(result))
2816
+ return violations;
2817
+ check(result.status === "applied" || result.status === "failed", "outcome.result", path, "recording never returns a dialling result");
2818
+ if (result.status === "failed") {
2819
+ 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");
2820
+ }
2821
+ else if (result.status === "applied") {
2822
+ const expected = command.action === "pause" ? "paused" : command.action === "start" || command.action === "resume" ? "active" : "inactive";
2823
+ check(after.status === expected, "outcome.state", path, "applied requires the confirming requested state");
2824
+ check(after.observationId !== before.observationId, "outcome.observation", path, "an applied transition needs new confirming evidence");
2825
+ check(before.observationId === command.observationId, "outcome.stale", path, "command must act against its named observation");
2826
+ const origin = command.action === "start" ? ["inactive"] : command.action === "pause" ? ["active"] : command.action === "resume" ? ["paused"] : ["active", "paused"];
2827
+ check(origin.includes(before.status), "outcome.origin", path, "applied cannot claim an impossible transition");
2828
+ if (command.action !== "start")
2829
+ check(command.recordingId === before.recordingId, "outcome.recordingId", path, "command must target the prior recording");
2830
+ if (command.action === "pause" || command.action === "resume")
2831
+ check(after.recordingId === before.recordingId, "outcome.continuity", path, "pause/resume preserves recording identity");
2832
+ if (instant(before.observedAt) && instant(after.observedAt))
2833
+ check(after.observedAt >= before.observedAt, "outcome.order", path, "clock discontinuity requires reconciliation, not a reversed confirmation");
2834
+ }
2835
+ return violations;
2836
+ }
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 is `2`. Version 2 replaces the broad recording flag and untargeted recording command; version 1 is not negotiated by this package. Hosts and adapters must explicitly migrate together or retain their own version-1 implementation.
62
62
 
63
63
  ### `Manifest.supportedProtocolVersions`
64
64
 
@@ -289,7 +289,57 @@ 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
+ type RecordingCancelEffect = "retain" | "discard";
296
+ interface RecordingActions {
297
+ start?: true;
298
+ pause?: true;
299
+ resume?: true;
300
+ stop?: true;
301
+ cancel?: { effect: RecordingCancelEffect };
302
+ }
303
+ interface TaskRecordingPolicy {
304
+ provider?: RecordingActions;
305
+ host?: RecordingActions & { destinationId: string };
306
+ }
307
+ type RecordingState =
308
+ | { status: "unknown"; observationId?: never; observedAt?: never; validUntil?: never; recordingId?: never }
309
+ | ({ observationId: string; observedAt: IsoTimestamp; validUntil: IsoTimestamp } & (
310
+ | { status: "inactive"; recordingId?: never }
311
+ | { status: "active" | "paused"; recordingId: string }
312
+ ));
313
+ type RecordingCommand = {
314
+ type: "recording";
315
+ source: RecordingSource;
316
+ requestId: string;
317
+ observationId: string;
318
+ } & (
319
+ | { action: "start"; recordingId?: never; cancelEffect?: never }
320
+ | { action: "pause" | "resume" | "stop"; recordingId: string; cancelEffect?: never }
321
+ | { action: "cancel"; recordingId: string; cancelEffect: RecordingCancelEffect }
322
+ );
323
+ interface HostRecordingReport {
324
+ taskId: TaskId;
325
+ allocationId: AllocationId;
326
+ state: RecordingState;
327
+ }
328
+ interface HostRecording {
329
+ actions: RecordingAction[];
330
+ cancelEffects: RecordingCancelEffect[];
331
+ destinationIds: string[];
332
+ execute(request: HostRecordingRequest): Promise<RecordingCommandResult>;
333
+ }
334
+ type RecordingCommandResult = Exclude<TaskCommandResult, { status: "dialling" }>;
335
+ interface HostRecordingRequest {
336
+ taskId: TaskId;
337
+ allocationId: AllocationId;
338
+ command: RecordingCommand & { source: "host" };
339
+ }
340
+
292
341
  type HostReport = {
342
+ recordings?: HostRecordingReport[];
293
343
  online: boolean;
294
344
  audio?: {
295
345
  input: HostAudioInput;
@@ -303,6 +353,7 @@ type HostGuarantees = {
303
353
  };
304
354
 
305
355
  type Host = {
356
+ recording?: HostRecording;
306
357
  guarantees: HostGuarantees;
307
358
  mute?: HostMute;
308
359
  report(): HostReport;
@@ -327,7 +378,7 @@ type ConnectContext = {
327
378
  log?: (entry: unknown) => void;
328
379
  } & (
329
380
  | { phone: "softphone"; host: { mute: HostMute } }
330
- | { phone?: "deskPhone"; host: { mute?: never } }
381
+ | { phone?: "deskPhone"; host: { mute?: never; recording?: never } }
331
382
  );
332
383
 
333
384
  type TransportStatus = "connecting" | "active" | "error";
@@ -511,7 +562,7 @@ type TaskCapabilities<C extends Channel = Channel> =
511
562
  warmTransfer?: Lockable<DestinationDirectory>;
512
563
  leadAssist?: Lockable<true>;
513
564
  conference?: Lockable<DestinationDirectory>;
514
- recording?: Lockable<true>;
565
+ recording?: Lockable<TaskRecordingPolicy>;
515
566
  }
516
567
  : C extends "chat"
517
568
  ? SharedTaskCapabilities & { decline?: Lockable<true>; hold?: Lockable<true> }
@@ -686,8 +737,8 @@ type Task<C extends Channel = Channel> = {
686
737
  handlingHistory?: TaskHandlingHistory;
687
738
  } & TaskCompletion & (
688
739
  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 }
740
+ ? { recording?: { provider?: RecordingState }; onCall?: OnCall[]; leadAssist?: TaskLeadAssist; assisting?: TaskAssisting; monitoring?: TaskMonitoring; media?: TaskMediaState }
741
+ : { recording?: never; onCall?: never; leadAssist?: never; assisting?: never; monitoring?: never; media?: never }
691
742
  );
692
743
 
693
744
  type PreviewDeadline = "calls" | "expires";
@@ -753,7 +804,7 @@ type VoiceTaskCommand =
753
804
  | { type: "conference"; action: "add"; dialId: DialId; destinationId: string }
754
805
  | { type: "conference"; action: "remove"; destinationId: string; party?: never }
755
806
  | { type: "conference"; action: "remove"; party: true; destinationId?: never }
756
- | { type: "recording"; action: "start" | "pause" | "resume" | "stop" }
807
+ | (RecordingCommand & { source: "provider" })
757
808
  | ({ type: "complete" } & DispositionPayload);
758
809
 
759
810
  type ChatTaskCommand =
@@ -2994,7 +3045,7 @@ See **Which commands need a capability**.
2994
3045
  | `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
3046
  | `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
3047
  | `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. |
3048
+ | `recording` | Overflow menu item: Recording | Per-task provider and host policies expose only their permitted recording actions; each has independent state and routing. |
2998
3049
  | `dispositions` | Primary button: Complete | Omni may request task disposal with a provider disposition and notes. |
2999
3050
 
3000
3051
  ### Publishing codes and destinations
@@ -4216,7 +4267,7 @@ capability's `notes` setting. A task publishing no codes still receives `complet
4216
4267
 
4217
4268
  Every command reaches the provider through `execute`, with no branch at the call site, and every
4218
4269
  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
4270
+ Provider-targeted `recording` commands and the rest act on the platform's own call leg, its bridge, or its record of the
4220
4271
  task. Nothing has happened until the provider applies them, and `failed` means nothing happened.
4221
4272
 
4222
4273
  **The provider performs every action; the host only offers it.** A control drawn on the host is
@@ -4325,7 +4376,7 @@ declared:
4325
4376
  | Everything else | Its own named capability. |
4326
4377
 
4327
4378
  **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
4379
+ `hold`, `resume` and `pause`, `end-call`, every `transfer` and `conference` action, and
4329
4380
  every `lead-assist` action. Each acts on the call or the conversation, and only while there is one.
4330
4381
  Before `in-progress` nothing has been placed or opened; in `completing` the handling has ended -- a
4331
4382
  call with nobody on it, a conversation closed -- and a wrap-up that still shows Transfer shows it
@@ -4940,3 +4991,100 @@ belongs in this contract is the boundary. A provider says what a control **is**
4940
4991
  capabilities and what its work is **called** through `phaseLabels` and `taskTypePresentation`; how
4941
4992
  any of it is drawn is Omni's. A task cannot select a design language, inject a component, or
4942
4993
  override the agent's theme and font preferences.
4994
+
4995
+ ## Independent task recording (protocol 2)
4996
+
4997
+ Recording is voice-only. A task can arrive already recording, including while pending, and offer
4998
+ no recording controls. The provider publishes only its own current state on
4999
+ the provider field of the task’s `recording` state. The host publishes its own full scoped view in `HostReport.recordings`.
5000
+ A provider task update cannot replace host state. Neither a capability nor an accepted command
5001
+ establishes recording. Missing state, lost recorder observation, transport loss and expired
5002
+ observation mean unknown; they never mean stopped. Paused means a recording remains open without
5003
+ capturing. Task hold, task pause and recording pause are independent.
5004
+
5005
+ The per-task policy is `Task.capabilities.recording`, with independently optional provider and host
5006
+ action sets. The outer capability may be locked. Absence grants no permission, and a state may
5007
+ exist without any permission. A provider may offer only stop for a recording started automatically,
5008
+ or withdraw a control on a later task update. There is no global recording mode and no automatic
5009
+ start from a capability declaration. Host support is declared on `ConnectContext.host.recording`,
5010
+ not the provider-owned manifest. The task chooses an explicitly provisioned host destination;
5011
+ unknown destinations or unsupported actions are refused visibly, never redirected to provider
5012
+ recording or a default upload location. Initial host support requires voice softphone media and
5013
+ capture of both local and remote audio; a host unable to capture either must refuse start/resume.
5014
+ This package declares that contract; it supplies no recorder, media mixing, storage or upload.
5015
+
5016
+ | Action | Required current recording state | Confirmed outcome |
5017
+ | --- | --- | --- |
5018
+ | start | inactive | active with a new recording identity |
5019
+ | pause | active | paused, preserving recording identity and captured audio |
5020
+ | resume | paused | active with the same recording identity |
5021
+ | stop | active or paused | inactive; finish and retain captured audio |
5022
+ | cancel | active or paused | inactive; abandon with the explicitly declared captured-audio disposition |
5023
+
5024
+ Cancel must declare retain or discard in task policy and echo that exact effect in the command.
5025
+ Retain abandons further capture while preserving captured audio; discard additionally requires
5026
+ confirmed disposal of that recording's audio under the recorder's storage contract. Neither
5027
+ means cancelling an in-flight start request or deleting arbitrary past recordings. A recorder
5028
+ unable to guarantee the offered effect must not advertise it. Stop can be applied only after
5029
+ finalization/retention succeeds; cancel-discard only after cessation and disposition succeed.
5030
+ Partial success (capture stopped but storage outcome unknown) cannot return failed with a claim
5031
+ of no effect. It rejects with unknown outcome, reports the failure visibly and publishes whatever
5032
+ current capture state is actually known. Retention is not a promise of sample-perfect audio.
5033
+
5034
+ Provider commands go exclusively to `Connection.execute`; host commands go exclusively to
5035
+ `HostRecording.execute`. Both include task, allocation, request and observation identities; all
5036
+ non-start commands identify the particular recording. IDs are opaque and scoped by provider login,
5037
+ task and recorder owner. They are never inferred from filenames or current agent identity. A
5038
+ recording ID survives pause/resume and reallocation only where the same recorder confirms continuity;
5039
+ commands always name the current allocation. A later start gets a different ID. Reconnect does not
5040
+ create a new recording or fresh evidence. After lost continuity, use unknown until reconciled.
5041
+ There is no automatic restart, transfer to another recorder, or stop on task hold/disconnect.
5042
+ Task removal does not prove recording stopped: outstanding host recorders remain tracked by the
5043
+ host until its executor reconciles/finishes them, with visible unresolved cleanup failures.
5044
+
5045
+ Only start/resume require an in-progress or paused task with started media. Pause/stop/cancel may
5046
+ also finish an independently observed recorder while the task is completing. A pending task may
5047
+ show active recording, but agent controls wait until handling begins. The two recording paths may
5048
+ both be active. A command to either path has no implied effect on the other.
5049
+
5050
+ Each confirmed observation has a fresh opaque observation identity, a canonical UTC millisecond
5051
+ observation instant and an exclusive expiry. These times describe current evidence, never historical
5052
+ capture boundaries. A trusted observer-domain current time must satisfy observedAt <= now < validUntil.
5053
+ Use `effectiveRecordingState` with that explicitly trusted time; an unavailable clock yields unknown.
5054
+ Receipt, replay and task publication never extend freshness. Clock discontinuity invalidates evidence;
5055
+ consumers must invalidate their clock estimate and use monotonic aging so clock rollback cannot
5056
+ revive expired evidence. The executor compares observation identity and recording identity against
5057
+ its latest state atomically before I/O. An intervening observation or allocation change refuses the
5058
+ stale command; it never acts on a replacement recorder. Providers lacking trustworthy current-state
5059
+ identity or observation time publish unknown and offer no issuable controls.
5060
+
5061
+ Use `validateTask` and `validateTaskCommand` for published shape and static permissions.
5062
+ Immediately before dispatch, additionally use `validateRecordingRequest` against the full current
5063
+ task and explicit observer-domain time. For host commands also supply the current host declaration,
5064
+ full host report and softphone context. The same checks must run at the executing boundary;
5065
+ client validation alone is not authorization. The executor serializes operations on each recorder
5066
+ and durably correlates request identity with scope, action and outcome: an exact retry refers to the
5067
+ same operation; reuse with different contents is refused. It never silently retries an ambiguous
5068
+ operation. Implementations without reliable request correlation must refuse controls.
5069
+
5070
+ Keep command progress in host UI separately from authoritative state. Applied means the requested
5071
+ transition and disposition actually completed; publish the confirming task or host report before
5072
+ resolving applied. Failed means confirmed no effect. Rejected promise means outcome unknown:
5073
+ show the failure, reconcile that path and do not automatically retry or pretend inactive. Authoritative
5074
+ updates remain full current task/host views, not replayed provider events. These current-state fields
5075
+ create no handling-history entries and infer no actors, durations or historical capture boundaries.
5076
+
5077
+ Migration is explicit: replace the old true recording capability with per-path action policies,
5078
+ replace untargeted commands with scoped commands, and declare protocol 2 only after implementing
5079
+ these semantics. Existing version-1 adapters are refused by negotiation rather than silently mapped.
5080
+ This change does not publish a package or enable recording in any existing host/provider by itself.
5081
+
5082
+ `validateRecordingOutcome` checks confirming observations against recording-specific applied/failed
5083
+ semantics, including pause/resume identity and rejection of a dialling result. It cannot prove audio
5084
+ retention/disposal or source truth from a status flag; those remain executor obligations.
5085
+
5086
+ Host destination selection binds storage when start actually creates a recording. Existing recordings
5087
+ keep that binding through pause/resume/stop/cancel; a later policy cannot redirect their stored audio.
5088
+ The executor rejects a mismatched destination rather than moving or discarding another binding.
5089
+ Action permission does not authorize unattended invocation: host controls require the agent's explicit
5090
+ act, and provider authorization remains enforced at its authenticated command boundary.
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.81",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",