@xema/omni-protocol 0.1.48 → 0.1.50

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
@@ -494,7 +494,8 @@ export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ?
494
494
  decline?: Lockable<true>;
495
495
  mute?: Lockable<true>;
496
496
  hold?: Lockable<true>;
497
- agentDisconnect?: Lockable<true>;
497
+ /** The agent may end the whole call: everyone leaves and the media ends, the task stays for its wrap-up. */
498
+ endCall?: Lockable<true>;
498
499
  /** Connect back to the party while `completing`, whoever placed the call; the task returns to `in-progress`. */
499
500
  connectBack?: Lockable<true>;
500
501
  coldTransfer?: Lockable<DestinationDirectory>;
@@ -815,7 +816,7 @@ export type TaskOutcome = {
815
816
  failure: ProtocolFailure;
816
817
  };
817
818
  export declare const TASK_COMMAND_NAMES: {
818
- readonly voice: readonly ["answer", "decline", "call", "mute", "hold", "resume", "disconnect", "connect-back", "transfer", "lead-assist", "conference", "recording", "complete"];
819
+ readonly voice: readonly ["answer", "decline", "call", "mute", "hold", "resume", "end-call", "connect-back", "transfer", "lead-assist", "conference", "recording", "complete"];
819
820
  readonly chat: readonly ["accept", "decline", "pause", "resume", "complete"];
820
821
  readonly email: readonly ["accept", "decline", "complete"];
821
822
  };
@@ -840,8 +841,10 @@ export type VoiceTaskCommand = {
840
841
  type: "hold";
841
842
  } | {
842
843
  type: "resume";
843
- } | {
844
- type: "disconnect";
844
+ }
845
+ /** End the whole call: everyone leaves and the task's media ends; the task stays for its wrap-up. Gated by `endCall`. */
846
+ | {
847
+ type: "end-call";
845
848
  }
846
849
  /** Issuable only in `completing`, under the `connectBack` capability. Dials the party's own number, so it names none. */
847
850
  | {
@@ -900,11 +903,22 @@ export type VoiceTaskCommand = {
900
903
  dialId: DialId;
901
904
  destinationId: string;
902
905
  }
903
- /** Drop the `conferenced` entry on `Task.onCall` with this `destinationId`; while it still rings, this calls the dial off. */
906
+ /**
907
+ * Remove one person from the call, named as `onCall` names them, and the call goes on for the
908
+ * rest: a `conferenced` entry by its `destinationId`, which while it still rings calls the dial
909
+ * off, or the `party`, leaving the agent with the colleague. A remove that would leave the agent
910
+ * alone is `end-call`, and a provider answers it `failed`.
911
+ */
904
912
  | {
905
913
  type: "conference";
906
914
  action: "remove";
907
915
  destinationId: string;
916
+ party?: never;
917
+ } | {
918
+ type: "conference";
919
+ action: "remove";
920
+ party: true;
921
+ destinationId?: never;
908
922
  } | {
909
923
  type: "recording";
910
924
  action: "start" | "pause" | "resume" | "stop";
package/dist/index.js CHANGED
@@ -72,7 +72,7 @@ export function effectiveLevels(declared) {
72
72
  // Task commands.
73
73
  // ---------------------------------------------------------------------------
74
74
  export const TASK_COMMAND_NAMES = {
75
- voice: ["answer", "decline", "call", "mute", "hold", "resume", "disconnect",
75
+ voice: ["answer", "decline", "call", "mute", "hold", "resume", "end-call",
76
76
  "connect-back", "transfer", "lead-assist", "conference", "recording", "complete"],
77
77
  chat: ["accept", "decline", "pause", "resume", "complete"],
78
78
  email: ["accept", "decline", "complete"],
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, validateManifest, validateResult, validateSnapshot, validateTimeZone, validatePhone, isTimeZone, } from "./validation.js";
2
+ import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateManifest, validateResult, validateSnapshot, validateTimeZone, validatePhone, 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
@@ -257,18 +257,6 @@ export async function exerciseAdapter(adapter, context, options = {}) {
257
257
  violations.push(...validateHostGuarantees(context.host.guarantees, "context.host.guarantees"));
258
258
  // The zone is the host's to state, so a context without one is a host that cannot exist.
259
259
  violations.push(...validateTimeZone(context.timeZone, "context.timeZone"));
260
- // A provider that reads its own clock passes the round trip on any machine in the host's zone,
261
- // which for a demo and a runner in one datacentre is most of them. A conformance run states a
262
- // zone this machine is not in, so the only way the identity carries it is that the provider
263
- // took it from the host.
264
- if (isTimeZone(context.timeZone)) {
265
- const canonical = (zone) => new Intl.DateTimeFormat("en", { timeZone: zone }).resolvedOptions().timeZone;
266
- const local = Intl.DateTimeFormat().resolvedOptions().timeZone;
267
- if (canonical(context.timeZone) === canonical(local)) {
268
- violations.push({ rule: "context.timeZone.local", path: "context.timeZone",
269
- message: `a conformance run states a zone this machine is not in; ${context.timeZone} is this machine's, so a provider reading its own clock would pass the round trip` });
270
- }
271
- }
272
260
  // How the agent hears the call decides what the host owns: on a softphone the host has the
273
261
  // audio and reports it; on a desk phone, or off voice, there is none for it to report.
274
262
  violations.push(...validatePhone(context.phone, adapter.manifest, "context.phone"));
@@ -354,7 +342,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
354
342
  // The provider republishes the agent's day: once connected, the identity carries the zone the
355
343
  // host sent. That proves the round trip, not the store -- an echo passes it -- and the name
356
344
  // says only what it tests; storage is proved by a colleague's zone arriving from describeUsers().
357
- if (isTimeZone(context.timeZone) && current().identity.timeZone !== context.timeZone) {
345
+ // A zone is judged by what it denotes: Asia/Kolkata and Asia/Calcutta are one zone, and a
346
+ // provider that keeps the canonical name has kept the zone.
347
+ if (isTimeZone(context.timeZone) && !sameTimeZone(current().identity.timeZone, context.timeZone)) {
358
348
  violations.push({
359
349
  rule: "authentication.identity.timeZone.republished",
360
350
  path: "authentication.identity.timeZone",
@@ -63,6 +63,14 @@ export declare function validateHostGuarantees(guarantees: unknown, path?: strin
63
63
  */
64
64
  export declare function validateHandlingReport(report: unknown, path?: string, manifest?: unknown): ProtocolViolation[];
65
65
  export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
66
+ /**
67
+ * What a command needs to be issuable, checked against the task it names: the capability the
68
+ * guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
69
+ * consulted entry, a lead requested, somebody else on the call. Without a task only the command's
70
+ * own shape is checked. A host validates before sending, and an adapter before acting: a command
71
+ * for a control the task never offered is the host's error, and this names it.
72
+ */
73
+ export declare function validateTaskCommand(command: unknown, task?: unknown, path?: string): ProtocolViolation[];
66
74
  /** The connection methods whose results `validateResult` knows. */
67
75
  export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamLeadAssist" | "executeTeamMonitor" | "openMedia" | "setPreference" | "recordStep" | "executeTeamPolicy";
68
76
  /**
@@ -74,6 +82,8 @@ export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" |
74
82
  export declare function validateResult(result: unknown, method: ResultMethod, path?: string, dialId?: string): ProtocolViolation[];
75
83
  /** An IANA zone name the runtime knows: `Asia/Kolkata` is one, an offset or a made-up name is not. */
76
84
  export declare function isTimeZone(value: unknown): value is string;
85
+ /** Whether two zone names denote one zone: `Asia/Kolkata` and `Asia/Calcutta` do, and a name is judged by what it denotes. */
86
+ export declare function sameTimeZone(a: unknown, b: unknown): boolean;
77
87
  /** The zone a host sends at connect: required, and an IANA name. */
78
88
  export declare function validateTimeZone(value: unknown, path?: string): ProtocolViolation[];
79
89
  /** What a login is validated against beyond its own shape. */
@@ -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, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
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";
13
13
  export class ProtocolConformanceError extends Error {
14
14
  violations;
15
15
  constructor(violations, summary = "Adapter violates the Omni protocol") {
@@ -96,7 +96,7 @@ const ISOLATION_SCHEME_VALUES = Object.values(BROWSER_ISOLATION_SCHEMES);
96
96
  const TASK_CAPABILITIES = {
97
97
  voice: membersOf({
98
98
  browsers: true, dispositions: true, custom: true, decline: true, mute: true, hold: true,
99
- agentDisconnect: true, connectBack: true, coldTransfer: true, warmTransfer: true, leadAssist: true, conference: true, recording: true,
99
+ endCall: true, connectBack: true, coldTransfer: true, warmTransfer: true, leadAssist: true, conference: true, recording: true,
100
100
  }),
101
101
  chat: membersOf({ browsers: true, dispositions: true, custom: true, decline: true, hold: true }),
102
102
  email: membersOf({ browsers: true, dispositions: true, custom: true, decline: true }),
@@ -1710,6 +1710,178 @@ export function validateHostReport(report, path = "host") {
1710
1710
  }
1711
1711
  return into.violations;
1712
1712
  }
1713
+ // ---------------------------------------------------------------------------
1714
+ // Commands.
1715
+ // ---------------------------------------------------------------------------
1716
+ const TRANSFER_ACTIONS = ["cold", "warm", "complete", "cancel"];
1717
+ const LEAD_ASSIST_ACTIONS = ["request", "cancel", "take-over", "leave"];
1718
+ const CONFERENCE_ACTIONS = ["add", "remove"];
1719
+ const RECORDING_ACTIONS = ["start", "pause", "resume", "stop"];
1720
+ /**
1721
+ * What a command needs to be issuable, checked against the task it names: the capability the
1722
+ * guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
1723
+ * consulted entry, a lead requested, somebody else on the call. Without a task only the command's
1724
+ * own shape is checked. A host validates before sending, and an adapter before acting: a command
1725
+ * for a control the task never offered is the host's error, and this names it.
1726
+ */
1727
+ export function validateTaskCommand(command, task, path = "command") {
1728
+ const into = new Collector();
1729
+ if (!isPlainObject(command)) {
1730
+ into.add("command.shape", path, "a command must be an object");
1731
+ return into.violations;
1732
+ }
1733
+ const type = command.type;
1734
+ const channel = isPlainObject(task) && typeof task.channel === "string" && isChannel(task.channel) ? task.channel : undefined;
1735
+ const names = channel !== undefined
1736
+ ? TASK_COMMAND_NAMES[channel]
1737
+ : [...new Set([...TASK_COMMAND_NAMES.voice, ...TASK_COMMAND_NAMES.chat, ...TASK_COMMAND_NAMES.email])];
1738
+ if (type === "custom") {
1739
+ into.filled(command.name, "command.custom.name", `${path}.name`, "a custom command names the control it presses");
1740
+ return into.violations;
1741
+ }
1742
+ if (!into.require(typeof type === "string" && names.includes(type), "command.type", `${path}.type`, channel === undefined ? `unsupported command: ${String(type)}` : `a ${channel} task has no ${String(type)} command`)) {
1743
+ return into.violations;
1744
+ }
1745
+ const dial = (rule) => into.filled(command.dialId, rule, `${path}.dialId`, "a command that dials carries the host's dialId");
1746
+ switch (type) {
1747
+ case "mute":
1748
+ into.require(typeof command.muted === "boolean", "command.mute.muted", `${path}.muted`, "mute says whether the agent is muted");
1749
+ break;
1750
+ case "call":
1751
+ dial("command.call.dialId");
1752
+ break;
1753
+ case "connect-back":
1754
+ dial("command.connectBack.dialId");
1755
+ break;
1756
+ case "transfer":
1757
+ if (into.oneOf(command.action, TRANSFER_ACTIONS, "command.transfer.action", `${path}.action`)) {
1758
+ if (command.action === "cold" || command.action === "warm") {
1759
+ dial("command.transfer.dialId");
1760
+ into.filled(command.destinationId, "command.transfer.destinationId", `${path}.destinationId`, "a transfer names the directory item it goes to");
1761
+ }
1762
+ else {
1763
+ into.require(command.dialId === undefined && command.destinationId === undefined, "command.transfer.unexpected", path, `${String(command.action)} names no destination: there is exactly one it could mean`);
1764
+ }
1765
+ }
1766
+ break;
1767
+ case "lead-assist":
1768
+ into.oneOf(command.action, LEAD_ASSIST_ACTIONS, "command.leadAssist.action", `${path}.action`);
1769
+ if (command.note !== undefined)
1770
+ into.filled(command.note, "command.leadAssist.note", `${path}.note`, "a note must not be empty when present");
1771
+ break;
1772
+ case "conference":
1773
+ if (into.oneOf(command.action, CONFERENCE_ACTIONS, "command.conference.action", `${path}.action`)) {
1774
+ if (command.action === "add") {
1775
+ dial("command.conference.dialId");
1776
+ into.filled(command.destinationId, "command.conference.destinationId", `${path}.destinationId`, "a conference names the directory item it dials in");
1777
+ }
1778
+ else {
1779
+ const party = command.party === true;
1780
+ const item = isFilled(command.destinationId);
1781
+ into.require(party !== item, "command.conference.remove.target", path, "a remove names one person: the party, or a conferenced entry by its destinationId, never both and never neither");
1782
+ }
1783
+ }
1784
+ break;
1785
+ case "recording":
1786
+ into.oneOf(command.action, RECORDING_ACTIONS, "command.recording.action", `${path}.action`);
1787
+ break;
1788
+ case "complete":
1789
+ if (command.disposition !== undefined)
1790
+ into.filled(command.disposition, "command.complete.disposition", `${path}.disposition`, "a disposition must not be empty when present");
1791
+ if (command.notes !== undefined)
1792
+ into.require(typeof command.notes === "string", "command.complete.notes", `${path}.notes`, "notes must be a string when present");
1793
+ break;
1794
+ default:
1795
+ break;
1796
+ }
1797
+ if (!isPlainObject(task))
1798
+ return into.violations;
1799
+ // What the task has to offer or be in for the command to be issuable.
1800
+ const capabilities = isPlainObject(task.capabilities) ? task.capabilities : {};
1801
+ const offered = (name) => {
1802
+ const declared = capabilities[name];
1803
+ if (declared === undefined) {
1804
+ into.add(`command.capability.${name}`, path, `${String(type)} needs the ${name} capability, and the task does not offer it`);
1805
+ return false;
1806
+ }
1807
+ if (isLocked(declared)) {
1808
+ into.add("command.capability.locked", path, `${name} stands locked by ${String(declared.lockedBy)}: the control is present without permission`);
1809
+ return false;
1810
+ }
1811
+ return true;
1812
+ };
1813
+ const inPhase = (rule, ...phases) => into.require(phases.includes(String(task.phase)), rule, path, `${String(type)} belongs to ${phases.join(" or ")}, and the task is ${String(task.phase)}`);
1814
+ const onCall = Array.isArray(task.onCall) ? task.onCall.filter(isPlainObject) : [];
1815
+ switch (type) {
1816
+ case "answer":
1817
+ case "accept":
1818
+ case "decline":
1819
+ case "reject":
1820
+ inPhase("command.phase.pending", "pending");
1821
+ if (type === "decline")
1822
+ offered("decline");
1823
+ break;
1824
+ case "call":
1825
+ inPhase("command.phase.preview", "preview");
1826
+ break;
1827
+ case "mute":
1828
+ offered("mute");
1829
+ break;
1830
+ case "hold":
1831
+ case "resume":
1832
+ case "pause":
1833
+ offered("hold");
1834
+ break;
1835
+ case "end-call":
1836
+ offered("endCall");
1837
+ break;
1838
+ case "recording":
1839
+ offered("recording");
1840
+ break;
1841
+ case "connect-back":
1842
+ offered("connectBack");
1843
+ inPhase("command.phase.completing", "completing");
1844
+ break;
1845
+ case "transfer":
1846
+ if (command.action === "cold")
1847
+ offered("coldTransfer");
1848
+ else if (command.action === "warm")
1849
+ offered("warmTransfer");
1850
+ else if (command.action === "complete" || command.action === "cancel") {
1851
+ into.require(onCall.some(entry => entry.role === "consulted"), "command.transfer.consulted", path, `${String(command.action)} needs a consulted entry on onCall: without one there is nothing to complete or cancel`);
1852
+ }
1853
+ break;
1854
+ case "lead-assist":
1855
+ if (command.action === "request" || command.action === "cancel") {
1856
+ offered("leadAssist");
1857
+ if (command.action === "cancel") {
1858
+ into.require(isPlainObject(task.leadAssist) && task.leadAssist.stage === "requested", "command.leadAssist.requested", path, "cancel needs a request standing: leadAssist with stage requested");
1859
+ }
1860
+ }
1861
+ else {
1862
+ into.require(task.assisting !== undefined, "command.leadAssist.assisting", path, `${String(command.action)} is the lead's own act on a call they joined: the task carries assisting`);
1863
+ }
1864
+ break;
1865
+ case "conference":
1866
+ if (offered("conference") && command.action === "remove") {
1867
+ const others = onCall.filter(entry => entry.role !== "agent");
1868
+ if (command.party === true) {
1869
+ into.require(others.some(entry => entry.role !== "party"), "command.conference.remove.alone", path, "removing the party would leave the agent alone: that is end-call");
1870
+ }
1871
+ else if (isFilled(command.destinationId)) {
1872
+ into.require(onCall.some(entry => entry.role === "conferenced" && entry.destinationId === command.destinationId), "command.conference.remove.unknown", path, `no conferenced entry on onCall has destinationId ${String(command.destinationId)}`);
1873
+ into.require(others.length > 1, "command.conference.remove.alone", path, "removing the last other person would leave the agent alone: that is end-call");
1874
+ }
1875
+ }
1876
+ break;
1877
+ case "complete":
1878
+ into.require(task.completionMode === "agent-command", "command.complete.mode", path, "complete belongs to agent-command; a provider-automatic task completes itself");
1879
+ break;
1880
+ default:
1881
+ break;
1882
+ }
1883
+ return into.violations;
1884
+ }
1713
1885
  // Pinned to the result unions: each method's one success status, and the status that carries a
1714
1886
  // failure. A method added to `Connection` without a row here is a compile error at the call site.
1715
1887
  const RESULT_STATUSES = {
@@ -1788,6 +1960,13 @@ export function isTimeZone(value) {
1788
1960
  return false;
1789
1961
  }
1790
1962
  }
1963
+ /** Whether two zone names denote one zone: `Asia/Kolkata` and `Asia/Calcutta` do, and a name is judged by what it denotes. */
1964
+ export function sameTimeZone(a, b) {
1965
+ if (!isTimeZone(a) || !isTimeZone(b))
1966
+ return false;
1967
+ const canonical = (zone) => new Intl.DateTimeFormat("en", { timeZone: zone }).resolvedOptions().timeZone;
1968
+ return a === b || canonical(a) === canonical(b);
1969
+ }
1791
1970
  /** The zone a host sends at connect: required, and an IANA name. */
1792
1971
  export function validateTimeZone(value, path = "context.timeZone") {
1793
1972
  const into = new Collector();
package/guide.md CHANGED
@@ -26,7 +26,7 @@ are used precisely throughout and mean nothing looser here.
26
26
  | **Task** | One unit of assigned work — a call, a chat, a mail. |
27
27
  | **Channel** | The kind of work a provider carries: `voice`, `chat`, or `email`. Fixed per provider by its manifest. |
28
28
  | **Task type** | The provider's own name for a category of work — a queue, a mailbox folder, a chat source. Free-form, and finer-grained than a channel. |
29
- | **Capability** | A provider's declaration that a control exists for a task or a session. It says *offer this*, and nothing about who carries it out — that is fixed per command, see **Where a command executes**. |
29
+ | **Capability** | A provider's declaration that a control exists for a task or a session. It says *offer this*; the provider performs it, and the host only offers it — see **Where a command executes**. |
30
30
  | **Login** | One authenticated sign-in to one provider, identified by `loginId`. A transport reconnect keeps it; signing in again replaces it, and nothing tied to the old `loginId` survives. |
31
31
  | **Transport** | The adapter's connection to its platform: a WebSocket or SignalR connection, required to be persistent and ordered. Which one and how it reconnects are the adapter's business; losing it does not end a login. |
32
32
  | **Connection** | The `Connection` object Omni holds for one login: the methods it can call and the events it receives. |
@@ -486,7 +486,7 @@ type TaskCapabilities<C extends Channel = Channel> =
486
486
  decline?: Lockable<true>;
487
487
  mute?: Lockable<true>;
488
488
  hold?: Lockable<true>;
489
- agentDisconnect?: Lockable<true>;
489
+ endCall?: Lockable<true>;
490
490
  connectBack?: Lockable<true>;
491
491
  coldTransfer?: Lockable<DestinationDirectory>;
492
492
  warmTransfer?: Lockable<DestinationDirectory>;
@@ -692,7 +692,7 @@ const TASK_COMMAND_NAMES = {
692
692
  "mute",
693
693
  "hold",
694
694
  "resume",
695
- "disconnect",
695
+ "end-call",
696
696
  "connect-back",
697
697
  "transfer",
698
698
  "lead-assist",
@@ -716,7 +716,7 @@ type VoiceTaskCommand =
716
716
  | { type: "mute"; muted: boolean }
717
717
  | { type: "hold" }
718
718
  | { type: "resume" }
719
- | { type: "disconnect" }
719
+ | { type: "end-call" }
720
720
  | { type: "connect-back"; dialId: DialId }
721
721
  | { type: "transfer"; action: "cold"; dialId: DialId; destinationId: string }
722
722
  | { type: "transfer"; action: "warm"; dialId: DialId; destinationId: string }
@@ -727,7 +727,8 @@ type VoiceTaskCommand =
727
727
  | { type: "lead-assist"; action: "take-over" }
728
728
  | { type: "lead-assist"; action: "leave" }
729
729
  | { type: "conference"; action: "add"; dialId: DialId; destinationId: string }
730
- | { type: "conference"; action: "remove"; destinationId: string }
730
+ | { type: "conference"; action: "remove"; destinationId: string; party?: never }
731
+ | { type: "conference"; action: "remove"; party: true; destinationId?: never }
731
732
  | { type: "recording"; action: "start" | "pause" | "resume" | "stop" }
732
733
  | ({ type: "complete" } & DispositionPayload);
733
734
 
@@ -1939,10 +1940,9 @@ field to fill in later: an identity without one is refused (`authentication.iden
1939
1940
  so is a zone that is not an IANA name, wherever it appears. Nothing is ever assumed in its place --
1940
1941
  not the viewer's browser, which gives a different answer per reader for the same record, and not the
1941
1942
  provider's clock. The harness holds a provider to the round trip: the identity carries the zone the
1942
- host stated (`authentication.identity.timeZone.republished`), and a conformance run states a zone
1943
- the machine running it is not in (`context.timeZone.local`): a provider that read its own clock
1944
- would otherwise pass the round trip on any runner in the host's zone, which for a demo and a runner
1945
- in one datacentre is most of them. **The round trip is not the store.**
1943
+ host stated (`authentication.identity.timeZone.republished`), judged by what the name denotes:
1944
+ `Asia/Kolkata` and `Asia/Calcutta` are one zone, and a provider that keeps the canonical name has
1945
+ kept the zone. **The round trip is not the store.**
1946
1946
  An adapter that echoes the stated zone back onto the identity passes that check with nothing kept,
1947
1947
  and a lead reading a colleague's day would still get the wrong one. What proves the store is a zone
1948
1948
  the run never sent: a colleague's `User` from `describeUsers()` carrying theirs. A provider's own
@@ -2681,12 +2681,12 @@ const taskCapabilities = {
2681
2681
  | `decline` | Pending-task button: Decline | The provider can decline a pending voice offer. Omni shows it only when provisioning also permits declining. |
2682
2682
  | `mute` | Primary toggle: Mute | Omni may mute and unmute the agent's outbound audio. |
2683
2683
  | `hold` | Primary toggle: Hold | Omni may issue voice-task `hold` and `resume` commands. |
2684
- | `agentDisconnect` | Primary button: Disconnect | Omni may disconnect real-time media without disposing the task. |
2684
+ | `endCall` | Primary button: End call | Omni may end the whole call: everyone leaves, the media ends, and the task stays for its wrap-up. See **Ending a call, and removing one person from it**. |
2685
2685
  | `connectBack` | Completing-task button: Connect back | Omni may have the provider connect the agent back to the task's party while the task is `completing`, returning it to `in-progress` on the same task. Not offered where there is no `completing` window: `provider-automatic` with a zero allowance disposes at provider end. See **Connecting back during completion**. |
2686
2686
  | `coldTransfer` | Secondary menu item: Cold transfer | Omni may hand the customer straight to a destination, with nobody spoken to first. |
2687
2687
  | `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**. |
2688
2688
  | `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**. |
2689
- | `conference` | Secondary button: Conference | Omni may dial a destination into the active call, and drop a `conferenced` entry from it -- one still ringing included, which calls the dial off. |
2689
+ | `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**. |
2690
2690
  | `recording` | Overflow menu item: Recording | Omni may expose start, pause, resume, and stop recording controls. |
2691
2691
  | `dispositions` | Primary button: Complete | Omni may request task disposal with a provider disposition and notes. |
2692
2692
 
@@ -2752,6 +2752,27 @@ chosen colleague bypasses all three. The desk shows no agent picker and no dial
2752
2752
  The one way a named person ends up with an agent's call is a lead taking it over through lead
2753
2753
  assist, which is the lead's act, not a transfer the agent chose.
2754
2754
 
2755
+ #### Ending a call, and removing one person from it
2756
+
2757
+ A call has everyone on `Task.onCall`, and two commands take people off it, both performed by the
2758
+ provider.
2759
+
2760
+ ```ts
2761
+ { type: "end-call" } // everyone leaves; the media ends; the task stays for its wrap-up
2762
+ { type: "conference", action: "remove", party: true } // the customer leaves; the agent stays with the colleague
2763
+ { type: "conference", action: "remove", destinationId: "tier2" } // the conferenced person leaves; ringing, this calls the dial off
2764
+ ```
2765
+
2766
+ `end-call` ends the whole conversation for everyone on it, and is gated by `endCall`. The task does
2767
+ not end with it: `task-media-ended` follows, any wrap allowance runs, and the agent dispositions
2768
+ the call. `conference` `remove` takes one person off, named as the room names them, and the call
2769
+ goes on for the rest; it is gated by `conference`, since it only means something with a third
2770
+ person on the line. Removing the party leaves the agent with the colleague, a warm hand-over in
2771
+ reverse. A remove that would leave the agent alone is not a remove but an `end-call`, and a
2772
+ provider answers it `failed`. The consulted destination of a warm transfer is not removed this way:
2773
+ `transfer` `cancel` is its own step, which returns the agent to the parked customer, and a remove
2774
+ never promises that.
2775
+
2755
2776
  #### Warm transfer
2756
2777
 
2757
2778
  A warm transfer parks the customer, calls the destination so the agent can speak to it first, and
@@ -3824,7 +3845,17 @@ already did.
3824
3845
  | Command | The provider's part |
3825
3846
  | --- | --- |
3826
3847
  | `mute` | **Record it, and keep the history.** The microphone is the host's, so Omni has already stopped the audio through `VoiceMediaSession.setMuted()` — no adapter can do that on the host's behalf. The command still arrives because the provider owns the task's record: it holds the current state for supervision, and each change as a `muted` handling step, exactly as it does for `held`. A platform that never hears about it shows a supervisor an agent who sounds absent for no reason, and reports a call with a silence it cannot explain. |
3827
- | Every other command | **Perform it.** `hold`, `transfer`, `conference`, `recording`, `disconnect` and the rest act on the platform's own call leg, its bridge, or its record of the task. Nothing has happened until the provider applies them. |
3848
+ | Every other command | **Perform it.** `hold`, `transfer`, `conference`, `end-call`, `recording` and the rest act on the platform's own call leg, its bridge, or its record of the task. Nothing has happened until the provider applies them. |
3849
+
3850
+ **The provider performs every action; the host only offers it.** A control drawn on the host is
3851
+ an affordance, never the enforcement: the host asks, and the provider does or declines. A provider
3852
+ answers `failed` for a command whose capability it did not publish, and that is the provider
3853
+ honouring its own declaration, not the host deciding policy -- a host that refuses a command on
3854
+ its own reading of a queue's flag has decided something that was never its to decide. What a
3855
+ command means on the platform is the provider's to work out: `end-call` ends the conversation, and
3856
+ which legs on which bridge that touches is a fact about the switch, never a choice the host makes.
3857
+ The one leg the host performs physically is the microphone, and even there the provider owns the
3858
+ record.
3828
3859
 
3829
3860
  **A failed `mute` does not unmute the agent.** The agent asked, Omni holds the microphone, and it
3830
3861
  is already done; a failure means only that the provider did not record it, leaving its view stale
@@ -3843,6 +3874,8 @@ declared:
3843
3874
  | Command | What makes it available |
3844
3875
  | --- | --- |
3845
3876
  | `answer`, `accept` | Nothing. A task that was offered can be accepted, or offering it meant nothing. |
3877
+ | `end-call` | The `endCall` capability. |
3878
+ | `conference` with `action: "remove"` | The `conference` capability, and somebody else on the call: a remove that would leave the agent alone is `end-call`, and a provider answers it `failed`. |
3846
3879
  | `decline` | The `decline` capability on any channel, **and** Omni provisioning permitting it. One word for refusing an offer, whatever the channel. |
3847
3880
  | `call` | The `preview` phase. A record put in front of an agent is there to be called, so the phase is the gate and there is no capability. It is a dial, with a `dialId` and a `dial-outcome`. |
3848
3881
  | `complete` | `completionMode: "agent-command"`. The `dispositions` capability decides whether a code travels with the command, never whether the command exists — a task Omni cannot complete never ends. |
@@ -3853,6 +3886,9 @@ declared:
3853
3886
  | `lead-assist` with `action: "take-over"` or `"leave"` | The lead's own task, on a call they joined -- `Task.assisting` present. An agent's task never has it, and a provider that receives either without it answers `failed`. |
3854
3887
  | Everything else | Its own named capability. |
3855
3888
 
3889
+ `validateTaskCommand(command, task)` holds a command to this table at runtime, both ways: the
3890
+ capability it needs, the phase it belongs to, and the state that has to stand.
3891
+
3856
3892
  Declining or rejecting a pending offer ends it without accepting or completing it. The provider
3857
3893
  confirms the end with `task-ended` and a `cancelled` outcome.
3858
3894
 
@@ -4205,6 +4241,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
4205
4241
  | `validateHostGuarantees(guarantees)` | What a host promises: only the guarantees this contract names, each declared by presence and never `false`. The harness validates the guarantees of whatever host a test hands the adapter. |
4206
4242
  | `validateHandlingReport(report, path?, manifest?)` | What the host reports of a leg it performed, for an adapter to check before forwarding: a task, a step, when it began, a positive `seconds` where stated, and an explicit `ended` that carries the final duration. Given the manifest, a running report is refused unless it declares `runningStepReports`. |
4207
4243
  | `validateHostReport(report)` | The host's own report as published to an adapter: `online`, and where there is audio, an input that is `available` with the microphone and `flowing`, or `unavailable` with a reason and the failure that says why, and an output that is `available` or `unavailable` with its failure. The harness validates whatever host a test hands the adapter; `stillHost(report)` builds one that never changes. |
4244
+ | `validateTaskCommand(command, task?)` | What a command needs to be issuable, against the task it names: its own shape -- a dial's `dialId`, a transfer's item, a remove naming exactly one person -- and, with the task, the capability the table above gates it on (`command.capability.<name>`, `.locked`), the phase it belongs to (`command.phase.*`), and the state that has to stand: a consulted entry, a lead requested, somebody else still on the call (`command.conference.remove.alone`). A host validates before sending and an adapter before acting. |
4208
4245
  | `validateResult(result, method)` | What a connection method answered: the status it gives, a failure where the status says so and nowhere else, the failure's shape, and that an `omni.` code is one this contract names. |
4209
4246
  | `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. Omni applies it to every state a session publishes — the republished as much as the first. |
4210
4247
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.48",
3
+ "version": "0.1.50",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",