@xema/omni-protocol 0.1.6 → 0.1.8

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,15 +53,17 @@ 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
60
60
  > if the helper is gutted, so pair every positive case with the violating twin.
61
61
 
62
- ## Two things TypeScript will not catch for you
62
+ ## Three things TypeScript will not catch for you
63
63
 
64
- Both found by adapters against this contract, and both produce a green build over a wrong shape.
64
+ All found by adapters against this contract, and all produce a green build over a wrong shape.
65
+ Two share one cause: TypeScript checks extra keys only on a literal that is the thing directly
66
+ assigned. Move the literal anywhere else and the check is gone.
65
67
 
66
68
  **Conditional spreads are the blind spot on a task literal.** A key inside
67
69
  `...(cond ? { … } : {})` is never checked against the task type, and `satisfies Task<C>` on the
@@ -88,6 +90,19 @@ switch (command.action) {
88
90
  }
89
91
  ```
90
92
 
93
+ **A `const` fixture escapes excess-property checking.** Park a literal in a variable and it is no
94
+ longer the thing directly assigned — the same reason the conditional spread escapes — so a shared
95
+ test fixture keeps a field the contract has dropped: `tsc` says nothing and the suite is
96
+ confidently green over a shape that no longer exists. Annotate the `const` or `satisfies` it where
97
+ it is declared; either names the field on the next build.
98
+
99
+ ```ts
100
+ take({ reasonId, requestedAt }); // error: requestedAt
101
+ const request = { reasonId, requestedAt }; take(request); // no error — the hole
102
+ const request: BreakRequest = { reasonId, requestedAt }; // error: requestedAt
103
+ const request = { reasonId, requestedAt } satisfies BreakRequest; // error: requestedAt
104
+ ```
105
+
91
106
  ## Building
92
107
 
93
108
  ```
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,39 @@ 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 means *unknown*: the provider
918
+ may have done it or not, and neither Omni nor an adapter on the agent's PC can find out in time to
919
+ make a repeat safe. Omni does not retry; if the agent acts again it is a new command.
922
920
 
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**.
921
+ On a persistent ordered transport there is only one way a command goes unsettled: the connection
922
+ went away underneath it. **An adapter that cannot settle a command has lost its transport, and
923
+ says so** `provider-status` `connecting`, reconnect, snapshot whichever channel the command
924
+ actually travelled on. An unsettled promise is therefore always followed by a snapshot, and that
925
+ snapshot is the answer; Omni waits for it rather than calling `snapshot()` itself. While the
926
+ transport is up, a result says the provider accepted the command, and the event that follows —
927
+ `task-updated`, `break-state` — says what it did.
927
928
 
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.
929
+ A command therefore carries no key. The provider names its own records — a task, a lead request, a
930
+ member and Omni refers to them by those names; **Omni never asks a provider to remember a name
931
+ Omni made up.**
932
+
933
+ A command whose second execution changes nothing needs no protection: committing a break that is
934
+ already committed stops an agent who is already stopped. A command whose second execution has a
935
+ cost — a dial places a second call — is not made safe by anyone, which is why it is never repeated
936
+ without a person deciding to.
937
+
938
+ An agent dials. The provider places the call and the answer is lost. Omni shows the dial as
939
+ unknown, not failed. Within a moment the provider offers the resulting call through `task-offered`
940
+ and the agent is on it; had nothing been placed, nothing arrives and the agent dials again. What
941
+ Omni must not do is dial again on the agent's behalf — the one outcome worse than a lost answer is
942
+ two phones ringing at the customer.
943
+
944
+ An agent presses Hold while the provider is reconnecting. Nothing is queued at either end: the
945
+ adapter answers `failed` with `omni.unavailable`, Omni shows the refusal, and the agent presses
946
+ again once the provider is `active` — against the state as it is then, rather than a held press
947
+ fired into a state that has moved on.
930
948
 
931
949
  ### 7. Work is pulled, never pushed
932
950
 
@@ -1394,7 +1412,7 @@ authorization codes, tokens, or provider responses containing secrets.
1394
1412
  ### Sign-out
1395
1413
 
1396
1414
  `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.
1415
+ session secrets, and moves state to `signed-out`.
1398
1416
  `close()` stops authentication-state observation but does not sign the agent out.
1399
1417
 
1400
1418
  ### Secure-storage boundary
@@ -1471,8 +1489,8 @@ surface in one place, and what obliges an adapter to implement each one.
1471
1489
  | `describeUsers(ids)` | The adapter publishes any `UserId`: on `ImposedBreak.by`, a roster, or `handlingHistory[].by`. |
1472
1490
  | `dial(request)` | The manifest declares `idleCapabilities.dial`. |
1473
1491
  | `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. |
1492
+ | `commitBreak()` | `sessionCapabilities.breaks` is declared. Commit and cancel are not optional halves of it. |
1493
+ | `cancelBreak()` | `sessionCapabilities.breaks` is declared. |
1476
1494
  | `endBreak()` | `sessionCapabilities.breaks` is declared. |
1477
1495
  | `executeTeamBreak(command)` | The adapter publishes a `TeamRoster` carrying `breakControl`. |
1478
1496
  | `executeTeamConsult(command)` | The adapter publishes a `TeamRoster` carrying `consultControl`. |
@@ -2158,11 +2176,9 @@ Custom capabilities must not redefine the meaning of a standard channel capabili
2158
2176
  Starts one outbound call from the idle dialpad. It is present only when the voice provider
2159
2177
  declares `dial`.
2160
2178
 
2161
- - `commandId` remains stable across retries; the provider must place at most one call for it.
2162
2179
  - `destination` is the original number selected or entered by the agent.
2163
2180
  - `source` is `contact` or `manual` and must comply with `destinationPolicy`.
2164
2181
  - `dialled` confirms that outbound call creation completed.
2165
- - `already-dialled` confirms a retry that placed no second call.
2166
2182
  - `failed` contains a `ProtocolFailure` and confirms no call was placed.
2167
2183
 
2168
2184
  The resulting call is offered through the normal `task-offered` event. A successful dial result
@@ -2178,7 +2194,6 @@ nothing while none are being accepted — so they are not published separately.
2178
2194
  | Field | Contract |
2179
2195
  | --- | --- |
2180
2196
  | `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
2197
  | `accepting` | Whether the agent may ask at all. Distinct from `approval`. |
2183
2198
  | `refusedReason` | Display-ready reason shown when `accepting` is false — a standing gate that applies to everyone. |
2184
2199
  | `decisionReason` | The words whoever decided attached, from `decide.reason`. About one request and one decision, not a standing gate. |
@@ -2204,8 +2219,8 @@ answer and ask again when they want to. `decisionReason` may carry the words att
2204
2219
  decision, but `approval` does not remain denied.
2205
2220
 
2206
2221
  A provider reports `starting-after-task` only after Omni commits a `granted` request while
2207
- work is still active. Omni does not retry the original request, because asking again would not move
2208
- it; it retries the commit when its delivery is uncertain.
2222
+ work is still active. Omni does not send the request again, because asking again would not move
2223
+ it; it sends the commit again only from a reconnect snapshot that shows the grant still standing.
2209
2224
 
2210
2225
  `accepting: false` is what lets Omni withdraw the control rather than let an agent ask and be
2211
2226
  refused. A `BreakReason` marked `alwaysAvailable` survives it: a mandatory rest period is not
@@ -2252,13 +2267,13 @@ branched on by code — in a log, a support ticket, a conformance failure — so
2252
2267
  rather than that something happened. `failed` is shared, because failing is the same act
2253
2268
  everywhere; success is not.
2254
2269
 
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` |
2270
+ | Method | Succeeded |
2271
+ | --- | --- |
2272
+ | `setCapacity` | `accepted` |
2273
+ | `requestBreak` | `requested` |
2274
+ | `commitBreak` | `committed` |
2275
+ | `cancelBreak` | `cancelled` |
2276
+ | `endBreak` | `ended` |
2262
2277
 
2263
2278
  `failed` carries a typed `ProtocolFailure` and means the provider did not take the action, whether
2264
2279
  it would not or could not.
@@ -2269,15 +2284,7 @@ again.
2269
2284
  Every break method reports its real result through `break-state`; `setCapacity` reports none at
2270
2285
  all, because capacity is a statement rather than a request.
2271
2286
 
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
2287
+ `execute` keeps `applied` rather than a verb per command, because the command
2281
2288
  is in the request: `execute({ command: { type: "hold" } })` returning `applied` already says the
2282
2289
  hold applied. A `held` result would repeat the discriminant that travelled with it.
2283
2290
 
@@ -2297,8 +2304,7 @@ go, and a provider that waits for it will stall.
2297
2304
  Your own tasks are the only ones you count. What the agent holds at other providers is not your
2298
2305
  concern — Omni set `count` knowing it.
2299
2306
 
2300
- Capacity supersedes rather than accumulates, so it carries no key and has no `already-` answer:
2301
- the latest value is the ceiling.
2307
+ Capacity supersedes rather than accumulates: the latest value is the ceiling.
2302
2308
 
2303
2309
  **Capacity gates what the provider allocates, not what the agent starts.** A call placed from the
2304
2310
  idle dialpad arrives through `task-offered` like any other task, and a full agent does not forbid
@@ -2306,9 +2312,8 @@ it: the ceiling binds allocation, not the agent's own hand.
2306
2312
 
2307
2313
  ### `requestBreak(request)`
2308
2314
 
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
2315
+ Requests permission to stop the agent later; it does not itself stop work. The provider continues
2316
+ offering work and reports `awaiting-decision` or `granted` through `break-state` events. If the request is denied, the
2312
2317
  provider reports `not-requested` directly, with `decisionReason` when one was supplied.
2313
2318
 
2314
2319
  #### Break reasons
@@ -2427,26 +2432,28 @@ Omni coordinates one attempt as follows:
2427
2432
  1. Freeze the participant set to every connected provider from which the agent can currently
2428
2433
  receive work. A provider joining during the attempt is given no capacity until it finishes.
2429
2434
  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.
2435
+ 3. Send one `requestBreak` to every participant. A provider reports `awaiting-decision` or
2436
+ `granted`; neither state stops work. A denial transitions directly to `not-requested` and
2437
+ causes Omni to take the cancel path.
2434
2438
  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
2436
- and reports `starting-after-task` or `in-effect`. Omni enters `on-break` once every participant
2437
- it can still reach reports `in-effect`, and no later than the **commit bound** ten seconds
2438
- from the decision, tunable per deployment. A participant that has not applied the commit by then
2439
- is set aside as unreconciled; the break begins without it.
2439
+ and send `commitBreak()` to every participant. A provider then stops offering new work
2440
+ and reports `starting-after-task` or `in-effect`. The **commit bound** ten seconds from the
2441
+ decision, tunable per deployment decides who is kept: a participant that has not applied the
2442
+ commit by then, still `granted` or unreachable, is set aside as unreconciled and the break
2443
+ begins without it. `in-effect` decides `on-break`: Omni enters it once every kept participant
2444
+ reports `in-effect`. A kept participant reporting `starting-after-task` has applied the commit
2445
+ and is finishing a task; the bound is on delivery, not on that task. Omni shows the break as
2446
+ settled and beginning when the task ends, and offers no cancel, because the commit is durable.
2440
2447
  5. If any participant fails or denies the request, cannot be reconciled within the bounded
2441
2448
  decision timeout, or the agent cancels before commit, durably choose cancel and enter
2442
- `cancelling-break`. Send `cancelBreak(requestId)` to every participant still reporting
2449
+ `cancelling-break`. Send `cancelBreak()` to every participant still reporting
2443
2450
  `awaiting-decision` or `granted`. Work continues during cancellation because no stop was
2444
2451
  committed. Return to `working` only after no participant retains either state.
2445
2452
 
2446
2453
  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
2454
+ never rolls that attempt back: it is reconciled by snapshot until every participant is stopped.
2455
+ A provider that reports `granted` must therefore preserve the request across reconnects and must
2456
+ honour a later commit or cancel. This
2450
2457
  durable promise prevents a provider from failing the commit after another provider has already
2451
2458
  stopped the agent.
2452
2459
 
@@ -2463,18 +2470,25 @@ on one platform while another keeps routing work to them — and **a provider Om
2463
2470
  routing nothing.** Setting it aside therefore costs none of the property it was protecting. Waiting
2464
2471
  for it costs the agent their break.
2465
2472
 
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.
2473
+ Setting a participant aside is not a rollback and not a cancel. The commit stands and the
2474
+ obligation stands: until that provider is stopped it has not stopped. When it returns it emits a
2475
+ snapshot before anything else, and the snapshot decides:
2476
+
2477
+ - `in-effect` or `starting-after-task` the commit arrived after all. Nothing to send.
2478
+ - still `granted` — the commit was lost. Omni sends `commitBreak()` now. If the original turns up
2479
+ late behind it, the provider stops an agent who is already stopped; nothing happens, because a
2480
+ commit is a state to be in, not an act to be done.
2481
+ - `not-requested` — the grant did not survive. Omni makes a new request for that provider alone,
2482
+ against an agent who is already on break elsewhere. **A new login is this case too**: the grant
2483
+ belonged to the old session.
2484
+
2485
+ The provider must not offer work in the meantime, and the commit is what stops it; Omni gives it
2486
+ no capacity until it is reconciled.
2471
2487
 
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.
2488
+ **If the agent has already ended the break elsewhere, the attempt is over** and the returning
2489
+ provider is reconciled to that instead: still `granted` gets `cancelBreak()`, because committing
2490
+ would stop an agent who is working again; `starting-after-task` or `in-effect` gets `endBreak()`.
2491
+ Never rolling back is about a break that is still on, not one the agent has finished.
2478
2492
 
2479
2493
  Omni may tell the agent which platforms the break has not yet reached, as it already does when a
2480
2494
  break cannot be paired across every provider.
@@ -2494,7 +2508,7 @@ soon as it reaches `granted`; it does not wait for unanimity because the agent h
2494
2508
  stopped elsewhere.
2495
2509
 
2496
2510
  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.
2511
+ the durable commit decision and snapshot reconciliation provide convergence after partial delivery.
2498
2512
 
2499
2513
  #### Reporting the break the agent is on
2500
2514
 
@@ -2505,19 +2519,19 @@ the agent on the break itself, the provider is the only one who knows.
2505
2519
  Omit it when you cannot say, and when there is no break: reporting a reason alongside
2506
2520
  `approval: "not-requested"` describes a break that is not happening, and is rejected.
2507
2521
 
2508
- ### `cancelBreak(requestId)`
2522
+ ### `cancelBreak()`
2509
2523
 
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`.
2524
+ Cancels the active pre-commit request while its approval is `awaiting-decision` or `granted`.
2525
+ Cancellation releases the request but does not restore work because work never stopped. If
2526
+ commit already won, the provider returns `omni.break-already-committed`. The resulting state is
2527
+ reported through `break-state`.
2514
2528
 
2515
- ### `commitBreak(requestId)`
2529
+ ### `commitBreak()`
2516
2530
 
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.
2531
+ Commits the `granted` request. Once the provider has reported `granted`, it cannot fail for a
2532
+ business reason. On commit the provider stops offering new work and reports
2533
+ `starting-after-task` while existing work finishes, or `in-effect` when the break is in effect.
2534
+ Committing a break that is already in effect changes nothing and answers `committed`.
2521
2535
 
2522
2536
  ### `endBreak()`
2523
2537
 
@@ -2603,7 +2617,7 @@ asks for a manager, a moment the agent wants a second pair of ears. The capabili
2603
2617
  team, and a second lead method beside `executeTeamBreak`:
2604
2618
 
2605
2619
  ```ts
2606
- executeTeamConsult({ commandId, command: TeamConsultCommand }): Promise<TeamCommandResult>
2620
+ executeTeamConsult({ command: TeamConsultCommand }): Promise<TeamCommandResult>
2607
2621
  ```
2608
2622
 
2609
2623
  Required when the roster carries `consultControl`, and gated by it exactly as `executeTeamBreak`
@@ -2611,15 +2625,15 @@ is by `breakControl`. The flow, in order:
2611
2625
 
2612
2626
  ```ts
2613
2627
  // 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" } })
2628
+ execute({ taskId: "call-42", command: { type: "lead", action: "request", note: "Refund dispute, needs approval" } })
2615
2629
  // task.lead = { status: "requested", note: "Refund dispute, needs approval", since }
2616
2630
 
2617
2631
  // 2. Every lead entitled to it sees the request on their roster.
2618
2632
  // team-updated: requests: [{ id: "req-7", memberId: "A-1", taskId: "call-42", note, since }]
2619
2633
 
2620
2634
  // 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" } })
2635
+ executeTeamConsult({ command: { type: "join", requestId: "req-7" } })
2636
+ executeTeamConsult({ command: { type: "decline", requestId: "req-7", reason: "In a call" } })
2623
2637
  ```
2624
2638
 
2625
2639
  **On `join` the provider bridges three parties and the lead is on a task of their own**, on the
@@ -2779,12 +2793,11 @@ Command names follow the channel's operational vocabulary, and each channel's co
2779
2793
  discriminated by `type` — the same discriminant `executeTeamBreak` and `custom` already use. The
2780
2794
  unions are declared under **Shapes**.
2781
2795
 
2782
- `taskId` is not repeated on the command. It travels on the `TaskCommandRequest` around it, with
2783
- `commandId`.
2796
+ `taskId` is not repeated on the command. It travels on the `TaskCommandRequest` around it.
2784
2797
 
2785
- **A toggle carries the state it wants, not a flip.** Inverting whatever is found cannot be
2786
- idempotent, and **Commands are idempotent** admits no exception: a retried flip turns something on
2787
- and then off again. `mute` therefore carries `muted`, and a custom `toggle` control carries its own
2798
+ **A toggle carries the state it wants, not a flip.** Inverting whatever is found cannot converge
2799
+ with a stale view: a flip against a state the provider has already changed turns something on and
2800
+ then off again. `mute` therefore carries `muted`, and a custom `toggle` control carries its own
2788
2801
  boolean. `hold` and `resume`, `pause` and `resume` need no flag, being pairs rather than toggles.
2789
2802
 
2790
2803
  **`complete` sends a disposition only where one was published.** `disposition` is a
@@ -2807,8 +2820,8 @@ is already done; a failure means only that the provider did not record it, leavi
2807
2820
  until the next snapshot. That is the safe direction to fail in, and it is the one place where
2808
2821
  `failed` does not mean *nothing happened* — everywhere else it does.
2809
2822
 
2810
- `mute` carries `muted` rather than flipping, so a retry and a stale view converge on the same
2811
- state instead of cancelling each other — see **Task commands**.
2823
+ `mute` carries `muted` rather than flipping, so a stale view converges on the stated state
2824
+ instead of flipping it back — see **Task commands**.
2812
2825
 
2813
2826
  ### Which commands need a capability
2814
2827
 
@@ -2836,22 +2849,23 @@ confirms the end with `task-ended` and a `cancelled` outcome.
2836
2849
 
2837
2850
  Applies a `TaskCommandRequest` to one provider-local task.
2838
2851
 
2839
- - `commandId` is globally unique, generated by Omni, and remains stable across retries.
2840
- - Omni serializes commands per task, never sends one command ID concurrently, records pending and
2841
- completed commands, retries only after an uncertain result, and stops retrying when `task-ended`
2842
- arrives.
2843
- - On an uncertain retry while the task remains active, the provider must apply each
2844
- `(taskId, commandId)` at most once.
2845
- - A repeated successfully applied command returns `already-applied` without repeating side
2846
- effects.
2847
- - `applied` confirms the command side effect completed.
2848
- - `failed` contains a typed `ProtocolFailure` and confirms the command was **not** applied. A
2849
- command either took effect or it did not; a provider that will not and a provider that cannot
2850
- report the same shape, and `code` says which.
2852
+ - Omni serializes commands per task and sends the next only after the previous settled or was
2853
+ given up as unknown; it stops sending when `task-ended` arrives.
2854
+ - `applied` confirms the side effect completed. `failed` confirms it did **not**, with a typed
2855
+ `ProtocolFailure`; a provider that will not and one that cannot report the same shape, and `code`
2856
+ says which.
2857
+ - A command sent while `provider-status` is not `active` answers `failed` with `omni.unavailable`.
2858
+ Neither Omni nor the adapter queues it.
2859
+ - **A command that asks for a state answers `applied` when that state holds, whoever brought it
2860
+ about; a command that acts answers `failed` when it cannot act.** Declining a lead request
2861
+ already gone is `applied`; joining one already gone is `failed`, since nobody joined. A lead
2862
+ deciding a member's break that another lead has already decided is `applied` when the decisions
2863
+ agree and `failed`, saying so in `message`, when they differ. `commitBreak()` on a break already
2864
+ in effect is `committed` for the same reason.
2851
2865
  - **A settled result is a fact; an unsettled promise is not.** Transport uncertainty may reject the
2852
- promise with no result at all, and that means *unknown*, not *failed*. Omni retries with the same
2853
- command ID, which is why idempotency is required — and why `failed` must never be returned for
2854
- something the provider is unsure of.
2866
+ promise with no result at all, and that means *unknown*, not *failed*, and a snapshot follows
2867
+ see **An unsettled result is unknown**. `failed` must never be returned for something the
2868
+ provider is unsure of, because Omni will show the agent it did not happen.
2855
2869
 
2856
2870
  ### `ProtocolFailure`
2857
2871
 
@@ -2873,7 +2887,7 @@ react rather than only display the message:
2873
2887
  | `omni.task-not-found` | The provider-local task id is unknown, typically after the task already ended. |
2874
2888
  | `omni.destination-not-permitted` | The dial or transfer destination violates the provider's policy. |
2875
2889
  | `omni.rate-limited` | The action was throttled. Pair with `retryAfterMs`. |
2876
- | `omni.unavailable` | The provider is temporarily unable to serve the action. |
2890
+ | `omni.unavailable` | The provider is temporarily unable to serve the action, including any command sent while `provider-status` is not `active`. |
2877
2891
  | `omni.break-already-committed` | Cancellation lost the commit/cancel race; Omni must finish commit recovery. |
2878
2892
 
2879
2893
  They are published as `OMNI_FAILURE_CODES`.
@@ -2930,9 +2944,10 @@ healthy provider from a dead one.
2930
2944
 
2931
2945
  #### Requesting a resync
2932
2946
 
2933
- Omni may call `snapshot()` at any time, not only at connect, and must do so on any loss of
2934
- confidence in its provider state. `reason: "provider-requested"` covers the
2935
- opposite direction — the provider asking Omni to reconcile and neither replaces the other.
2947
+ Omni calls `snapshot()` at connect; after that, snapshots come to it on reconnect, and when the
2948
+ provider asks. It may still call `snapshot()` at any time, but never to learn a command's fate:
2949
+ the reconnect snapshot already carries it. `reason: "provider-requested"` covers the opposite
2950
+ direction — the provider asking Omni to reconcile — and neither replaces the other.
2936
2951
 
2937
2952
  ### `snapshot`
2938
2953
 
@@ -2974,9 +2989,9 @@ reasons, retry details, and any imposed break.
2974
2989
  For a multi-provider attempt, "every provider" is the participant set frozen when the attempt
2975
2990
  entered `requesting-break`. Omni commits only after every participant reports `granted` —
2976
2991
  that one is unconditional, because nothing has stopped yet and waiting costs only time. It enters
2977
- `on-break` once every participant it can still reach reports `in-effect`, and no later than the
2978
- commit bound: past that a participant is set aside as unreconciled rather than holding a break that
2979
- has already begun elsewhere. Otherwise it follows the two-phase rules under **Coordinating a
2992
+ `on-break` once every kept participant reports `in-effect`; the commit bound decides who is kept,
2993
+ setting aside a participant that has not applied the commit rather than holding a break that has
2994
+ already begun elsewhere. Otherwise it follows the two-phase rules under **Coordinating a
2980
2995
  multi-provider break**.
2981
2996
 
2982
2997
  ### `task-offered`
@@ -3125,12 +3140,6 @@ Two properties of the harness matter to adapter authors:
3125
3140
  `close()` run in a `finally` block, and a throw from any of them is reported as
3126
3141
  `disconnectWasClean: false` rather than being hidden.
3127
3142
 
3128
- ### `assertCommandIdempotency(connection, request)`
3129
-
3130
- Issues the same command twice and verifies that the first call applies (or was already applied)
3131
- and the retry returns `already-applied`. Use a deterministic test task because this helper invokes
3132
- the adapter command method twice.
3133
-
3134
3143
  ### Contract scenarios
3135
3144
 
3136
3145
  The testing entry point also exports deterministic, reusable checks for lifecycle behavior that
@@ -3141,8 +3150,6 @@ cannot be established from TypeScript structure alone.
3141
3150
  | `assertAuthenticationRestoreAndExpiry(states)` | A restored authenticated session can refresh and ends in expiry. |
3142
3151
  | `assertReconnectWithMissedAssignments(before, reconnect, ids)` | A reconnect snapshot restores assignments received while offline. |
3143
3152
  | `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
3144
- | `assertCommandIdempotency(connection, request)` | Retrying a task command does not repeat its side effect. |
3145
- | `assertDialIdempotency(connection, request)` | Retrying a dial command does not place another call. |
3146
3153
  | `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. |
3147
3154
  | `assertBrowserIsolationAndReuse(left, right, expected)` | Browser reuse follows only the declared isolation scheme. |
3148
3155
  | `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.6",
3
+ "version": "0.1.8",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",