@xema/omni-protocol 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -53,7 +53,7 @@ expect(result.disconnectWasClean).toBe(true);
53
53
  ```
54
54
 
55
55
  Run the contract scenarios beside it — authentication restore and expiry, reconnect with missed
56
- assignments, break denial and retry, command idempotency, wrap timeout, browser isolation.
56
+ assignments, break denial and retry, wrap timeout, browser isolation.
57
57
 
58
58
  > **Assert both directions.** Every helper rejects a violating input as well as accepting a
59
59
  > conforming one. A suite that only asserts "this conforming case does not throw" passes unchanged
package/dist/index.d.ts CHANGED
@@ -579,33 +579,29 @@ export interface CustomTaskCommand {
579
579
  }
580
580
  export type TaskCommand<C extends Channel = Channel> = (C extends "voice" ? VoiceTaskCommand : C extends "chat" ? ChatTaskCommand : EmailTaskCommand) | CustomTaskCommand;
581
581
  export interface TaskCommandRequest<C extends Channel = Channel> {
582
- /** Stable across retries. Processing one twice must not repeat its side effects. */
583
- commandId: string;
584
582
  taskId: TaskId;
585
583
  command: TaskCommand<C>;
586
584
  }
587
585
  /**
588
- * `applied` and `already-applied` rather than a verb per command: the command travels in the
589
- * request, so `execute({ command: { type: "hold" } })` returning `applied` already says the hold
590
- * applied.
586
+ * `applied` rather than a verb per command: the command travels in the request, so
587
+ * `execute({ command: { type: "hold" } })` returning `applied` already says the hold applied.
588
+ *
589
+ * A promise that rejects with no result means *unknown*, not `failed`. Omni does not retry --
590
+ * no adapter on the agent's PC can make a repeat safe -- and the next snapshot shows what the
591
+ * provider did. A command therefore carries no key of Omni's making.
591
592
  */
592
593
  export type TaskCommandResult = {
593
- commandId: string;
594
- status: "applied" | "already-applied";
594
+ status: "applied";
595
595
  } | {
596
- commandId: string;
597
596
  status: "failed";
598
597
  failure: ProtocolFailure;
599
598
  };
600
599
  export interface DialRequest {
601
- commandId: string;
602
600
  destination: string;
603
601
  }
604
602
  export type DialResult = {
605
- commandId: string;
606
- status: "dialled" | "already-dialled";
603
+ status: "dialled";
607
604
  } | {
608
- commandId: string;
609
605
  status: "failed";
610
606
  failure: ProtocolFailure;
611
607
  };
@@ -635,8 +631,6 @@ export interface BreakReason {
635
631
  alwaysAvailable?: true;
636
632
  }
637
633
  export interface BreakRequest {
638
- /** Stable across retries, and what makes `already-requested` recognisable. */
639
- requestId: string;
640
634
  reason?: string;
641
635
  /** The chosen `BreakReason.id`, where the provider publishes codes. */
642
636
  reasonId?: string;
@@ -659,7 +653,6 @@ export type ImposedBreak = {
659
653
  };
660
654
  export interface BreakState {
661
655
  approval: BreakApproval;
662
- requestId?: string;
663
656
  /** Whether the agent may ask at all. Distinct from the fate of a request already made. */
664
657
  accepting: boolean;
665
658
  /** Shown when `accepting` is false, such as "Busy hours". */
@@ -680,31 +673,26 @@ export type CapacityResult = {
680
673
  };
681
674
  /** Succeeding is not the outcome: `requested` says the provider holds it, not that it was granted. */
682
675
  export type BreakRequestResult = {
683
- requestId: string;
684
- status: "requested" | "already-requested";
676
+ status: "requested";
685
677
  } | {
686
- requestId: string;
687
678
  status: "failed";
688
679
  failure: ProtocolFailure;
689
680
  };
681
+ /** Committing a break already in effect changes nothing and answers `committed`. */
690
682
  export type BreakCommitResult = {
691
- requestId: string;
692
- status: "committed" | "already-committed";
683
+ status: "committed";
693
684
  } | {
694
- requestId: string;
695
685
  status: "failed";
696
686
  failure: ProtocolFailure;
697
687
  };
698
688
  export type BreakCancelResult = {
699
- requestId: string;
700
- status: "cancelled" | "already-cancelled";
689
+ status: "cancelled";
701
690
  } | {
702
- requestId: string;
703
691
  status: "failed";
704
692
  failure: ProtocolFailure;
705
693
  };
706
694
  export type BreakEndResult = {
707
- status: "ended" | "already-ended";
695
+ status: "ended";
708
696
  } | {
709
697
  status: "failed";
710
698
  failure: ProtocolFailure;
@@ -746,7 +734,6 @@ export type TeamConsultCommand = {
746
734
  reason?: string;
747
735
  };
748
736
  export interface TeamConsultCommandRequest {
749
- commandId: string;
750
737
  command: TeamConsultCommand;
751
738
  }
752
739
  export type TeamBreakCommand = {
@@ -766,15 +753,12 @@ export type TeamBreakCommand = {
766
753
  memberId: UserId;
767
754
  };
768
755
  export type TeamCommandResult = {
769
- commandId: string;
770
- status: "applied" | "already-applied";
756
+ status: "applied";
771
757
  } | {
772
- commandId: string;
773
758
  status: "failed";
774
759
  failure: ProtocolFailure;
775
760
  };
776
761
  export interface TeamBreakCommandRequest {
777
- commandId: string;
778
762
  command: TeamBreakCommand;
779
763
  }
780
764
  export interface VoiceMediaSession {
@@ -909,8 +893,8 @@ export interface Connection<C extends Channel = Channel> {
909
893
  * never start, and the two-phase coordination has no way to report it.
910
894
  */
911
895
  requestBreak?(request: BreakRequest): Promise<BreakRequestResult>;
912
- commitBreak?(requestId: string): Promise<BreakCommitResult>;
913
- cancelBreak?(requestId: string): Promise<BreakCancelResult>;
896
+ commitBreak?(): Promise<BreakCommitResult>;
897
+ cancelBreak?(): Promise<BreakCancelResult>;
914
898
  endBreak?(): Promise<BreakEndResult>;
915
899
  /** Required when the adapter publishes a `TeamRoster` carrying `breakControl`. */
916
900
  executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
package/dist/testing.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Connection, type Snapshot, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type DialRequest, type TaskCommandRequest } from "./index.js";
1
+ import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext } from "./index.js";
2
2
  import { type ProtocolViolation } from "./validation.js";
3
3
  export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation } from "./validation.js";
4
4
  export interface AdapterContractResult {
@@ -25,8 +25,6 @@ export interface ExerciseAdapterOptions {
25
25
  * asynchronous one — which would let a non-conforming async adapter pass.
26
26
  */
27
27
  export declare function exerciseAdapter<C extends Channel>(adapter: Adapter<C>, context: ConnectContext, options?: ExerciseAdapterOptions): Promise<AdapterContractResult>;
28
- /** Verifies the at-most-once contract by issuing the same command twice. */
29
- export declare function assertCommandIdempotency(connection: Pick<Connection, "execute">, request: TaskCommandRequest): Promise<void>;
30
28
  /** Validates restored authentication followed by a refresh failure or expiry. */
31
29
  export declare function assertAuthenticationRestoreAndExpiry(states: readonly AuthenticationState[]): void;
32
30
  /** Validates duplicate delivery and returns the event sequence Omni applies once per ID. */
@@ -45,8 +43,6 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
45
43
  * both.
46
44
  */
47
45
  export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
48
- /** Verifies that retrying one dial command cannot place a second call. */
49
- export declare function assertDialIdempotency(connection: Pick<Connection, "dial">, request: DialRequest): Promise<void>;
50
46
  /**
51
47
  * Validates the deadline derived from media end and the task's fixed wrap allowance.
52
48
  *
package/dist/testing.js CHANGED
@@ -143,21 +143,6 @@ function publishesUserIds(snapshot) {
143
143
  return false;
144
144
  return snapshot.tasks.some(task => Array.isArray(task?.handlingHistory) && task.handlingHistory.some(step => step?.by !== undefined));
145
145
  }
146
- /** Verifies the at-most-once contract by issuing the same command twice. */
147
- export async function assertCommandIdempotency(connection, request) {
148
- const first = await connection.execute(request);
149
- if (first.commandId !== request.commandId)
150
- throw new Error("Command result id mismatch");
151
- if (first.status === "failed") {
152
- throw new Error(`Command failed: ${first.failure.code}`);
153
- }
154
- const retry = await connection.execute(request);
155
- if (retry.commandId !== request.commandId)
156
- throw new Error("Retried command result id mismatch");
157
- if (retry.status !== "already-applied") {
158
- throw new Error(`Retried command must return already-applied, received ${retry.status}`);
159
- }
160
- }
161
146
  /** Validates restored authentication followed by a refresh failure or expiry. */
162
147
  export function assertAuthenticationRestoreAndExpiry(states) {
163
148
  if (states.length < 2 || states[0]?.status !== "authenticated") {
@@ -231,24 +216,6 @@ export function assertDeniedAndRetriedBreak(approvals) {
231
216
  throw new Error(`Break retry scenario must end granted or in effect, ended ${String(last)}`);
232
217
  }
233
218
  }
234
- /** Verifies that retrying one dial command cannot place a second call. */
235
- export async function assertDialIdempotency(connection, request) {
236
- if (!connection.dial)
237
- throw new Error("Dial capability requires Connection.dial()");
238
- const first = await connection.dial(request);
239
- if (first.commandId !== request.commandId)
240
- throw new Error("Dial result id mismatch");
241
- if (first.status === "failed")
242
- throw new Error(`Dial failed: ${first.failure.code}`);
243
- const retry = await connection.dial(request);
244
- if (retry.commandId !== request.commandId)
245
- throw new Error("Retried dial result id mismatch");
246
- // Each method answers in its own words: a retried dial says already-dialled, not
247
- // already-applied, because what it did was dial.
248
- if (retry.status !== "already-dialled") {
249
- throw new Error(`Retried dial must return already-dialled, received ${retry.status}`);
250
- }
251
- }
252
219
  /**
253
220
  * Validates the deadline derived from media end and the task's fixed wrap allowance.
254
221
  *
@@ -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, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, } from "./index.js";
12
+ import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, negotiateProtocolVersion, } from "./index.js";
13
13
  export class ProtocolConformanceError extends Error {
14
14
  violations;
15
15
  constructor(violations, summary = "Adapter violates the Omni protocol") {
@@ -271,6 +271,10 @@ export function validateManifest(manifest, path = "manifest") {
271
271
  versions.forEach((version, index) => {
272
272
  into.require(typeof version === "number" && Number.isInteger(version) && version > 0, "manifest.supportedProtocolVersions.value", `${path}.supportedProtocolVersions[${index}]`, "a protocol version must be a positive integer");
273
273
  });
274
+ // Interoperability: the adapter must speak a version this package does, or Omni must refuse
275
+ // to connect. Reported here so it is found with the manifest rather than at connect time.
276
+ const declared = versions.filter((version) => typeof version === "number");
277
+ into.require(negotiateProtocolVersion(declared) !== undefined, "manifest.supportedProtocolVersions.interoperable", `${path}.supportedProtocolVersions`, `this host speaks protocol version${OMNI_SUPPORTED_PROTOCOL_VERSIONS.length === 1 ? "" : "s"} ${OMNI_SUPPORTED_PROTOCOL_VERSIONS.join(", ")}; the adapter declares none of them`);
274
278
  }
275
279
  const methods = manifest.authenticationMethods;
276
280
  if (!Array.isArray(methods) || methods.length === 0) {
@@ -434,9 +438,14 @@ function validateBrowsers(value, path, into) {
434
438
  }
435
439
  // Reuse and its scheme travel together. A reusing browser with no scheme would otherwise
436
440
  // inherit whatever a host happened to default to, which is how two tasks end up sharing a
437
- // session nobody intended.
441
+ // session nobody intended. The guide names the rule for the missing case.
438
442
  if (browser.reuse === true) {
439
- into.require(ISOLATION_SCHEME_VALUES.includes(browser.isolationScheme), "task.browser.isolationScheme", `${at}.isolationScheme`, `a reusing browser must declare one of: ${ISOLATION_SCHEME_VALUES.join(", ")}`);
443
+ if (browser.isolationScheme === undefined) {
444
+ into.add("task.browser.isolationScheme.required", `${at}.isolationScheme`, `a reusing browser must declare one of: ${ISOLATION_SCHEME_VALUES.join(", ")}`);
445
+ }
446
+ else {
447
+ into.require(ISOLATION_SCHEME_VALUES.includes(browser.isolationScheme), "task.browser.isolationScheme", `${at}.isolationScheme`, `an isolation scheme must be one of: ${ISOLATION_SCHEME_VALUES.join(", ")}`);
448
+ }
440
449
  }
441
450
  else if (browser.reuse === false) {
442
451
  into.require(browser.isolationScheme === undefined, "task.browser.isolationScheme.unexpected", `${at}.isolationScheme`, "a browser that does not reuse must not declare an isolation scheme");
@@ -654,7 +663,7 @@ function validateBreakState(value, path, into) {
654
663
  }
655
664
  into.oneOf(value.approval, BREAK_APPROVALS, "break.approval", `${path}.approval`);
656
665
  into.require(typeof value.accepting === "boolean", "break.accepting", `${path}.accepting`, "accepting must be a boolean");
657
- for (const field of ["requestId", "refusedReason", "decisionReason"]) {
666
+ for (const field of ["refusedReason", "decisionReason"]) {
658
667
  if (value[field] !== undefined) {
659
668
  into.filled(value[field], `break.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
660
669
  }
package/guide.md CHANGED
@@ -542,7 +542,6 @@ type TaskCommand<C extends Channel = Channel> =
542
542
  | CustomTaskCommand;
543
543
 
544
544
  type TaskCommandRequest<C extends Channel = Channel> = {
545
- commandId: string;
546
545
  taskId: TaskId;
547
546
  command: TaskCommand<C>;
548
547
  };
@@ -567,7 +566,6 @@ type BreakReason = {
567
566
  };
568
567
 
569
568
  type BreakRequest = {
570
- requestId: string;
571
569
  reason?: string;
572
570
  reasonId?: string;
573
571
  };
@@ -578,7 +576,6 @@ type ImposedBreak =
578
576
 
579
577
  type BreakState = {
580
578
  approval: BreakApproval;
581
- requestId?: string;
582
579
  accepting: boolean;
583
580
  refusedReason?: string;
584
581
  decisionReason?: string;
@@ -915,18 +912,27 @@ Events report completed transactions after that baseline. Nothing is missed whil
915
912
  holds; when it drops, the reconnect snapshot re-establishes the baseline before any further event
916
913
  is applied.
917
914
 
918
- ### 6. Commands are idempotent
915
+ ### 6. An unsettled result is unknown, and unknown is not retried
919
916
 
920
- Handle every command as though it may arrive twice a retry, a reconnect, an agent pressing
921
- twice.
917
+ A settled result is a fact. A promise that rejects with no result the transport died — means
918
+ *unknown*: the provider may have done it or not, and neither Omni nor an adapter on the agent's PC
919
+ can find out in time to make a repeat safe. Omni does not retry. The next snapshot shows what the
920
+ provider actually did, and if the agent acts again it is a new command.
922
921
 
923
- Every retryable call carries a stable key `commandId` on `execute` and `dial`, `requestId` on the
924
- break methods. Processing the same key more than once must not repeat its side effects, and a
925
- retry is answered with that method's `already-` form: `already-applied`, `already-dialled`,
926
- `already-committed`, and so on. Each answers in its own words; see **Capacity and break actions**.
922
+ A command therefore carries no key. The provider names its own records a task, a lead request, a
923
+ member and Omni refers to them by those names; **Omni never asks a provider to remember a name
924
+ Omni made up.**
927
925
 
928
- `setCapacity` is the one exception and needs no key. A capacity supersedes rather than
929
- accumulates, so re-sending the current one is not a repeat of anything.
926
+ A command whose second execution changes nothing needs no protection: committing a break that is
927
+ already committed stops an agent who is already stopped. A command whose second execution has a
928
+ cost — a dial places a second call — is not made safe by anyone, which is why it is never repeated
929
+ without a person deciding to.
930
+
931
+ An agent dials. The provider places the call and the answer is lost. Omni shows the dial as
932
+ unknown, not failed. Within a moment the provider offers the resulting call through `task-offered`
933
+ and the agent is on it; had nothing been placed, nothing arrives and the agent dials again. What
934
+ Omni must not do is dial again on the agent's behalf — the one outcome worse than a lost answer is
935
+ two phones ringing at the customer.
930
936
 
931
937
  ### 7. Work is pulled, never pushed
932
938
 
@@ -1394,7 +1400,7 @@ authorization codes, tokens, or provider responses containing secrets.
1394
1400
  ### Sign-out
1395
1401
 
1396
1402
  `signOut(requestId)` revokes or invalidates the provider session where supported, deletes stored
1397
- session secrets, and moves state to `signed-out`. It is safe to retry with the same request ID.
1403
+ session secrets, and moves state to `signed-out`.
1398
1404
  `close()` stops authentication-state observation but does not sign the agent out.
1399
1405
 
1400
1406
  ### Secure-storage boundary
@@ -1471,8 +1477,8 @@ surface in one place, and what obliges an adapter to implement each one.
1471
1477
  | `describeUsers(ids)` | The adapter publishes any `UserId`: on `ImposedBreak.by`, a roster, or `handlingHistory[].by`. |
1472
1478
  | `dial(request)` | The manifest declares `idleCapabilities.dial`. |
1473
1479
  | `requestBreak(request)` | `sessionCapabilities.breaks` is declared. |
1474
- | `commitBreak(requestId)` | `sessionCapabilities.breaks` is declared. Commit and cancel are not optional halves of it. |
1475
- | `cancelBreak(requestId)` | `sessionCapabilities.breaks` is declared. |
1480
+ | `commitBreak()` | `sessionCapabilities.breaks` is declared. Commit and cancel are not optional halves of it. |
1481
+ | `cancelBreak()` | `sessionCapabilities.breaks` is declared. |
1476
1482
  | `endBreak()` | `sessionCapabilities.breaks` is declared. |
1477
1483
  | `executeTeamBreak(command)` | The adapter publishes a `TeamRoster` carrying `breakControl`. |
1478
1484
  | `executeTeamConsult(command)` | The adapter publishes a `TeamRoster` carrying `consultControl`. |
@@ -2158,11 +2164,9 @@ Custom capabilities must not redefine the meaning of a standard channel capabili
2158
2164
  Starts one outbound call from the idle dialpad. It is present only when the voice provider
2159
2165
  declares `dial`.
2160
2166
 
2161
- - `commandId` remains stable across retries; the provider must place at most one call for it.
2162
2167
  - `destination` is the original number selected or entered by the agent.
2163
2168
  - `source` is `contact` or `manual` and must comply with `destinationPolicy`.
2164
2169
  - `dialled` confirms that outbound call creation completed.
2165
- - `already-dialled` confirms a retry that placed no second call.
2166
2170
  - `failed` contains a `ProtocolFailure` and confirms no call was placed.
2167
2171
 
2168
2172
  The resulting call is offered through the normal `task-offered` event. A successful dial result
@@ -2178,7 +2182,6 @@ nothing while none are being accepted — so they are not published separately.
2178
2182
  | Field | Contract |
2179
2183
  | --- | --- |
2180
2184
  | `approval` | Where the agent's current request stands. See the states below. |
2181
- | `requestId` | Correlates an agent-requested break while approval is `awaiting-decision`, `granted`, `starting-after-task`, or `in-effect`. Omitted for imposed breaks and when no request is active. |
2182
2185
  | `accepting` | Whether the agent may ask at all. Distinct from `approval`. |
2183
2186
  | `refusedReason` | Display-ready reason shown when `accepting` is false — a standing gate that applies to everyone. |
2184
2187
  | `decisionReason` | The words whoever decided attached, from `decide.reason`. About one request and one decision, not a standing gate. |
@@ -2252,13 +2255,13 @@ branched on by code — in a log, a support ticket, a conformance failure — so
2252
2255
  rather than that something happened. `failed` is shared, because failing is the same act
2253
2256
  everywhere; success is not.
2254
2257
 
2255
- | Method | Succeeded | Retried after uncertain delivery |
2256
- | --- | --- | --- |
2257
- | `setCapacity` | `accepted` | — |
2258
- | `requestBreak` | `requested` | `already-requested` |
2259
- | `commitBreak` | `committed` | `already-committed` |
2260
- | `cancelBreak` | `cancelled` | `already-cancelled` |
2261
- | `endBreak` | `ended` | `already-ended` |
2258
+ | Method | Succeeded |
2259
+ | --- | --- |
2260
+ | `setCapacity` | `accepted` |
2261
+ | `requestBreak` | `requested` |
2262
+ | `commitBreak` | `committed` |
2263
+ | `cancelBreak` | `cancelled` |
2264
+ | `endBreak` | `ended` |
2262
2265
 
2263
2266
  `failed` carries a typed `ProtocolFailure` and means the provider did not take the action, whether
2264
2267
  it would not or could not.
@@ -2269,15 +2272,7 @@ again.
2269
2272
  Every break method reports its real result through `break-state`; `setCapacity` reports none at
2270
2273
  all, because capacity is a statement rather than a request.
2271
2274
 
2272
- `setCapacity` has no retry answer because it needs none: a capacity supersedes rather than
2273
- accumulates, and re-sending the current one changes nothing. The four break methods carry a
2274
- `requestId`
2275
- precisely so a retry can be recognised, and `already-committed` is the one commit recovery lives
2276
- on — retrying `commitBreak` into a partially delivered attempt, it is the difference between *I
2277
- have committed now* and *I committed before you asked*, which is how Omni knows the attempt has
2278
- converged rather than only that a message arrived.
2279
-
2280
- `execute` keeps `applied` and `already-applied` rather than a verb per command, because the command
2275
+ `execute` keeps `applied` rather than a verb per command, because the command
2281
2276
  is in the request: `execute({ command: { type: "hold" } })` returning `applied` already says the
2282
2277
  hold applied. A `held` result would repeat the discriminant that travelled with it.
2283
2278
 
@@ -2297,8 +2292,7 @@ go, and a provider that waits for it will stall.
2297
2292
  Your own tasks are the only ones you count. What the agent holds at other providers is not your
2298
2293
  concern — Omni set `count` knowing it.
2299
2294
 
2300
- Capacity supersedes rather than accumulates, so it carries no key and has no `already-` answer:
2301
- the latest value is the ceiling.
2295
+ Capacity supersedes rather than accumulates: the latest value is the ceiling.
2302
2296
 
2303
2297
  **Capacity gates what the provider allocates, not what the agent starts.** A call placed from the
2304
2298
  idle dialpad arrives through `task-offered` like any other task, and a full agent does not forbid
@@ -2306,9 +2300,8 @@ it: the ceiling binds allocation, not the agent's own hand.
2306
2300
 
2307
2301
  ### `requestBreak(request)`
2308
2302
 
2309
- Requests permission to stop the agent later; it does not itself stop work. `requestId` is stable
2310
- across retries for one agent break attempt. The provider continues offering work and reports
2311
- `awaiting-decision` or `granted` through `break-state` events. If the request is denied, the
2303
+ Requests permission to stop the agent later; it does not itself stop work. The provider continues
2304
+ offering work and reports `awaiting-decision` or `granted` through `break-state` events. If the request is denied, the
2312
2305
  provider reports `not-requested` directly, with `decisionReason` when one was supplied.
2313
2306
 
2314
2307
  #### Break reasons
@@ -2427,26 +2420,25 @@ Omni coordinates one attempt as follows:
2427
2420
  1. Freeze the participant set to every connected provider from which the agent can currently
2428
2421
  receive work. A provider joining during the attempt is given no capacity until it finishes.
2429
2422
  2. Enter `requesting-break`. Keep the agent's normal capacity in place throughout this phase.
2430
- 3. Send one `requestBreak` to every participant, using a stable `requestId` per provider for this
2431
- logical attempt. Retry uncertain delivery with the same ID. A provider reports
2432
- `awaiting-decision` or `granted`; neither state stops work. A denial transitions directly to
2433
- `not-requested` and causes Omni to take the cancel path.
2423
+ 3. Send one `requestBreak` to every participant. A provider reports `awaiting-decision` or
2424
+ `granted`; neither state stops work. A denial transitions directly to `not-requested` and
2425
+ causes Omni to take the cancel path.
2434
2426
  4. If every participant reports `granted`, durably choose commit, enter `committing-break`,
2435
- and send `commitBreak(requestId)` to every participant. A provider then stops offering new work
2427
+ and send `commitBreak()` to every participant. A provider then stops offering new work
2436
2428
  and reports `starting-after-task` or `in-effect`. Omni enters `on-break` once every participant
2437
2429
  it can still reach reports `in-effect`, and no later than the **commit bound** — ten seconds
2438
2430
  from the decision, tunable per deployment. A participant that has not applied the commit by then
2439
2431
  is set aside as unreconciled; the break begins without it.
2440
2432
  5. If any participant fails or denies the request, cannot be reconciled within the bounded
2441
2433
  decision timeout, or the agent cancels before commit, durably choose cancel and enter
2442
- `cancelling-break`. Send `cancelBreak(requestId)` to every participant still reporting
2434
+ `cancelling-break`. Send `cancelBreak()` to every participant still reporting
2443
2435
  `awaiting-decision` or `granted`. Work continues during cancellation because no stop was
2444
2436
  committed. Return to `working` only after no participant retains either state.
2445
2437
 
2446
2438
  Commit and cancel are mutually exclusive decisions for one attempt. Once Omni chooses commit it
2447
- never rolls that attempt back: uncertain deliveries are retried with the same ID and reconciled by
2448
- snapshot until every participant applies the commit. A provider that reports `granted` must
2449
- therefore preserve the request across reconnects and must honour a later commit or cancel. This
2439
+ never rolls that attempt back: it is reconciled by snapshot until every participant is stopped.
2440
+ A provider that reports `granted` must therefore preserve the request across reconnects and must
2441
+ honour a later commit or cancel. This
2450
2442
  durable promise prevents a provider from failing the commit after another provider has already
2451
2443
  stopped the agent.
2452
2444
 
@@ -2463,18 +2455,19 @@ on one platform while another keeps routing work to them — and **a provider Om
2463
2455
  routing nothing.** Setting it aside therefore costs none of the property it was protecting. Waiting
2464
2456
  for it costs the agent their break.
2465
2457
 
2466
- Setting a participant aside is not a rollback and not a cancel. The commit stands, the `requestId`
2467
- stands, and the obligation stands: Omni re-sends `commitBreak(requestId)` when that provider
2468
- returns, and until it applies the commit that provider has not stopped. Because the commit is idempotent
2469
- the answer is `already-committed` if it applied the first one after all, and `committed` if it did
2470
- not — which is how Omni tells a slow delivery from a lost one, and why that pair exists.
2458
+ Setting a participant aside is not a rollback and not a cancel. The commit stands and the
2459
+ obligation stands: until that provider is stopped it has not stopped. When it returns it emits a
2460
+ snapshot before anything else, and the snapshot decides:
2471
2461
 
2472
- Reconnection reconciles the rest. A returning provider emits a snapshot before anything else, so
2473
- Omni sees its break state and re-sends the commit if it is missing; it must not offer work in the
2474
- meantime, and the commit is what stops it. **A new login is a different case**: the
2475
- `requestId` belonged to the old `sessionId` and the grant did not survive it, so Omni does not
2476
- recover that attempt against a fresh session. It makes a new request for that provider alone,
2477
- against an agent who is already on break elsewhere.
2462
+ - `in-effect` or `starting-after-task` the commit arrived after all. Nothing to send.
2463
+ - still `granted` the commit was lost. Omni sends `commitBreak()` now. If the original turns up
2464
+ late behind it, the provider stops an agent who is already stopped; nothing happens, because a
2465
+ commit is a state to be in, not an act to be done.
2466
+ - `not-requested` the grant did not survive. Omni makes a new request for that provider alone,
2467
+ against an agent who is already on break elsewhere. **A new login is this case too**: the grant
2468
+ belonged to the old session.
2469
+
2470
+ The provider must not offer work in the meantime, and the commit is what stops it.
2478
2471
 
2479
2472
  Omni may tell the agent which platforms the break has not yet reached, as it already does when a
2480
2473
  break cannot be paired across every provider.
@@ -2494,7 +2487,7 @@ soon as it reaches `granted`; it does not wait for unanimity because the agent h
2494
2487
  stopped elsewhere.
2495
2488
 
2496
2489
  This is two-phase coordination across vendor systems: the approval phase keeps the agent working;
2497
- the durable commit decision and idempotent retries provide convergence after partial delivery.
2490
+ the durable commit decision and snapshot reconciliation provide convergence after partial delivery.
2498
2491
 
2499
2492
  #### Reporting the break the agent is on
2500
2493
 
@@ -2505,19 +2498,19 @@ the agent on the break itself, the provider is the only one who knows.
2505
2498
  Omit it when you cannot say, and when there is no break: reporting a reason alongside
2506
2499
  `approval: "not-requested"` describes a break that is not happening, and is rejected.
2507
2500
 
2508
- ### `cancelBreak(requestId)`
2501
+ ### `cancelBreak()`
2509
2502
 
2510
- Cancels the active pre-commit request identified by `requestId` while its approval is
2511
- `awaiting-decision` or `granted`. It is safe to retry. Cancellation releases the request but
2512
- does not restore work because work never stopped. If commit already won, the provider returns
2513
- `omni.break-already-committed`. The resulting state is reported through `break-state`.
2503
+ Cancels the active pre-commit request while its approval is `awaiting-decision` or `granted`.
2504
+ Cancellation releases the request but does not restore work because work never stopped. If
2505
+ commit already won, the provider returns `omni.break-already-committed`. The resulting state is
2506
+ reported through `break-state`.
2514
2507
 
2515
- ### `commitBreak(requestId)`
2508
+ ### `commitBreak()`
2516
2509
 
2517
- Commits the matching `granted` request. It is safe to retry and, once the provider has
2518
- reported `granted`, cannot fail for a business reason. On commit the provider stops
2519
- offering new work and reports `starting-after-task` while existing work finishes, or `in-effect` when
2520
- the break is in effect.
2510
+ Commits the `granted` request. Once the provider has reported `granted`, it cannot fail for a
2511
+ business reason. On commit the provider stops offering new work and reports
2512
+ `starting-after-task` while existing work finishes, or `in-effect` when the break is in effect.
2513
+ Committing a break that is already in effect changes nothing and answers `committed`.
2521
2514
 
2522
2515
  ### `endBreak()`
2523
2516
 
@@ -2603,7 +2596,7 @@ asks for a manager, a moment the agent wants a second pair of ears. The capabili
2603
2596
  team, and a second lead method beside `executeTeamBreak`:
2604
2597
 
2605
2598
  ```ts
2606
- executeTeamConsult({ commandId, command: TeamConsultCommand }): Promise<TeamCommandResult>
2599
+ executeTeamConsult({ command: TeamConsultCommand }): Promise<TeamCommandResult>
2607
2600
  ```
2608
2601
 
2609
2602
  Required when the roster carries `consultControl`, and gated by it exactly as `executeTeamBreak`
@@ -2611,15 +2604,15 @@ is by `breakControl`. The flow, in order:
2611
2604
 
2612
2605
  ```ts
2613
2606
  // 1. The agent asks, with a small note. Their task carries `lead` from here on.
2614
- execute({ commandId, taskId: "call-42", command: { type: "lead", action: "request", note: "Refund dispute, needs approval" } })
2607
+ execute({ taskId: "call-42", command: { type: "lead", action: "request", note: "Refund dispute, needs approval" } })
2615
2608
  // task.lead = { status: "requested", note: "Refund dispute, needs approval", since }
2616
2609
 
2617
2610
  // 2. Every lead entitled to it sees the request on their roster.
2618
2611
  // team-updated: requests: [{ id: "req-7", memberId: "A-1", taskId: "call-42", note, since }]
2619
2612
 
2620
2613
  // 3. A lead joins, or declines.
2621
- executeTeamConsult({ commandId, command: { type: "join", requestId: "req-7" } })
2622
- executeTeamConsult({ commandId, command: { type: "decline", requestId: "req-7", reason: "In a call" } })
2614
+ executeTeamConsult({ command: { type: "join", requestId: "req-7" } })
2615
+ executeTeamConsult({ command: { type: "decline", requestId: "req-7", reason: "In a call" } })
2623
2616
  ```
2624
2617
 
2625
2618
  **On `join` the provider bridges three parties and the lead is on a task of their own**, on the
@@ -2630,6 +2623,10 @@ trigger it; but from then on it is an outstanding task the provider counts again
2630
2623
  stated ceiling like any other, nothing more is allocated to the lead while it stands, and a
2631
2624
  provider whose lead is already at the ceiling answers the join `failed`.
2632
2625
 
2626
+ **A lead assists one call at a time.** A `join` from a lead already on a call -- their own or one
2627
+ they joined -- is answered `failed`, whatever their ceiling; the request stands for another lead,
2628
+ or until it is withdrawn or declined.
2629
+
2633
2630
  **On `decline`, or a request the agent withdraws with `{ type: "lead", action: "cancel" }`, the
2634
2631
  provider clears `lead` from the agent's task** and drops the request from every roster. Nothing
2635
2632
  else changes; the agent is still on the call.
@@ -2775,12 +2772,11 @@ Command names follow the channel's operational vocabulary, and each channel's co
2775
2772
  discriminated by `type` — the same discriminant `executeTeamBreak` and `custom` already use. The
2776
2773
  unions are declared under **Shapes**.
2777
2774
 
2778
- `taskId` is not repeated on the command. It travels on the `TaskCommandRequest` around it, with
2779
- `commandId`.
2775
+ `taskId` is not repeated on the command. It travels on the `TaskCommandRequest` around it.
2780
2776
 
2781
- **A toggle carries the state it wants, not a flip.** Inverting whatever is found cannot be
2782
- idempotent, and **Commands are idempotent** admits no exception: a retried flip turns something on
2783
- and then off again. `mute` therefore carries `muted`, and a custom `toggle` control carries its own
2777
+ **A toggle carries the state it wants, not a flip.** Inverting whatever is found cannot converge
2778
+ with a stale view: a flip against a state the provider has already changed turns something on and
2779
+ then off again. `mute` therefore carries `muted`, and a custom `toggle` control carries its own
2784
2780
  boolean. `hold` and `resume`, `pause` and `resume` need no flag, being pairs rather than toggles.
2785
2781
 
2786
2782
  **`complete` sends a disposition only where one was published.** `disposition` is a
@@ -2803,8 +2799,8 @@ is already done; a failure means only that the provider did not record it, leavi
2803
2799
  until the next snapshot. That is the safe direction to fail in, and it is the one place where
2804
2800
  `failed` does not mean *nothing happened* — everywhere else it does.
2805
2801
 
2806
- `mute` carries `muted` rather than flipping, so a retry and a stale view converge on the same
2807
- state instead of cancelling each other — see **Task commands**.
2802
+ `mute` carries `muted` rather than flipping, so a stale view converges on the stated state
2803
+ instead of flipping it back — see **Task commands**.
2808
2804
 
2809
2805
  ### Which commands need a capability
2810
2806
 
@@ -2832,22 +2828,15 @@ confirms the end with `task-ended` and a `cancelled` outcome.
2832
2828
 
2833
2829
  Applies a `TaskCommandRequest` to one provider-local task.
2834
2830
 
2835
- - `commandId` is globally unique, generated by Omni, and remains stable across retries.
2836
- - Omni serializes commands per task, never sends one command ID concurrently, records pending and
2837
- completed commands, retries only after an uncertain result, and stops retrying when `task-ended`
2838
- arrives.
2839
- - On an uncertain retry while the task remains active, the provider must apply each
2840
- `(taskId, commandId)` at most once.
2841
- - A repeated successfully applied command returns `already-applied` without repeating side
2842
- effects.
2843
- - `applied` confirms the command side effect completed.
2844
- - `failed` contains a typed `ProtocolFailure` and confirms the command was **not** applied. A
2845
- command either took effect or it did not; a provider that will not and a provider that cannot
2846
- report the same shape, and `code` says which.
2831
+ - Omni serializes commands per task and sends the next only after the previous settled or was
2832
+ given up as unknown; it stops sending when `task-ended` arrives.
2833
+ - `applied` confirms the side effect completed. `failed` confirms it did **not**, with a typed
2834
+ `ProtocolFailure`; a provider that will not and one that cannot report the same shape, and `code`
2835
+ says which.
2847
2836
  - **A settled result is a fact; an unsettled promise is not.** Transport uncertainty may reject the
2848
- promise with no result at all, and that means *unknown*, not *failed*. Omni retries with the same
2849
- command ID, which is why idempotency is required and why `failed` must never be returned for
2850
- something the provider is unsure of.
2837
+ promise with no result at all, and that means *unknown*, not *failed*. `failed` must never be
2838
+ returned for something the provider is unsure of, because Omni will show the agent it did not
2839
+ happen.
2851
2840
 
2852
2841
  ### `ProtocolFailure`
2853
2842
 
@@ -3121,12 +3110,6 @@ Two properties of the harness matter to adapter authors:
3121
3110
  `close()` run in a `finally` block, and a throw from any of them is reported as
3122
3111
  `disconnectWasClean: false` rather than being hidden.
3123
3112
 
3124
- ### `assertCommandIdempotency(connection, request)`
3125
-
3126
- Issues the same command twice and verifies that the first call applies (or was already applied)
3127
- and the retry returns `already-applied`. Use a deterministic test task because this helper invokes
3128
- the adapter command method twice.
3129
-
3130
3113
  ### Contract scenarios
3131
3114
 
3132
3115
  The testing entry point also exports deterministic, reusable checks for lifecycle behavior that
@@ -3137,8 +3120,6 @@ cannot be established from TypeScript structure alone.
3137
3120
  | `assertAuthenticationRestoreAndExpiry(states)` | A restored authenticated session can refresh and ends in expiry. |
3138
3121
  | `assertReconnectWithMissedAssignments(before, reconnect, ids)` | A reconnect snapshot restores assignments received while offline. |
3139
3122
  | `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
3140
- | `assertCommandIdempotency(connection, request)` | Retrying a task command does not repeat its side effect. |
3141
- | `assertDialIdempotency(connection, request)` | Retrying a dial command does not place another call. |
3142
3123
  | `assertWrapTimeout(task, mediaEndedAt, deadline, toleranceMs?)` | The wrap deadline equals media end plus the task allowance, within a tolerance that defaults to 1000ms; a task with no allowance has no deadline, and one observed is the violation. |
3143
3124
  | `assertBrowserIsolationAndReuse(left, right, expected)` | Browser reuse follows only the declared isolation scheme. |
3144
3125
  | `assertNoBrowserSessionKeyCollisions(scenarios)` | No two distinct scenarios derive the same session key. Feed it adversarial names. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",