@xema/omni-protocol 0.1.68 → 0.1.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1397,11 +1397,15 @@ export interface Snapshot<C extends Channel = Channel> {
1397
1397
  /**
1398
1398
  * How many tasks this provider may have allocated to the agent at once.
1399
1399
  *
1400
- * An absolute ceiling, never less than 1, standing until Omni restates it. The provider counts
1401
- * its own outstanding tasks against it and needs no new signal when one ends. What the agent
1402
- * holds at other providers is not this provider's concern -- Omni set `count` knowing it.
1400
+ * An absolute ceiling standing until Omni restates it. The provider counts its own outstanding
1401
+ * tasks against it and needs no new signal when one ends. The agent is one person on several
1402
+ * providers, and the host divides their capacity among them: `0` is host-stopped -- the agent's
1403
+ * capacity is elsewhere for now, this provider allocates nothing and shows the member as
1404
+ * `on-task` -- and is not a break, which is the agent not working at all. The host restates a
1405
+ * count of one or more when this provider has the agent's capacity again.
1403
1406
  */
1404
1407
  export interface AgentCapacity {
1408
+ /** A whole number, zero or more; zero is host-stopped. */
1405
1409
  count: number;
1406
1410
  }
1407
1411
  export interface SummaryMetric {
@@ -1507,10 +1511,24 @@ export interface ProviderEventEnvelope<C extends Channel = Channel> {
1507
1511
  }
1508
1512
  export declare const OMNI_FAILURE_CODES: readonly ["omni.not-authenticated", "omni.capability-not-enabled", "omni.task-not-found", "omni.destination-not-permitted", "omni.phone-not-permitted", "omni.rate-limited", "omni.unavailable", "omni.break-already-committed"];
1509
1513
  export type OmniFailureCode = (typeof OMNI_FAILURE_CODES)[number];
1514
+ /**
1515
+ * What the host refused, told to the adapter that published it: a snapshot the host would not
1516
+ * replace its state with, or an event it dropped, and every rule it broke. A refusal the provider
1517
+ * never hears of is a desk frozen at its last good state for a shift with nothing wrong on the
1518
+ * provider's side; this is how both sides see it.
1519
+ */
1520
+ export interface Refusal {
1521
+ artefact: "snapshot" | "event";
1522
+ /** The envelope refused, where the artefact is an event. */
1523
+ envelopeId?: string;
1524
+ violations: ProtocolViolation[];
1525
+ }
1510
1526
  export interface Connection<C extends Channel = Channel> {
1511
1527
  snapshot(): Snapshot<C> | Promise<Snapshot<C>>;
1512
1528
  /** Delivery order must match the order the provider observes changes. Never replay. */
1513
1529
  subscribe(listener: (envelope: ProviderEventEnvelope<C>) => void): Unsubscribe;
1530
+ /** Told what the host refused, snapshot or event, with every rule broken. Always; a refusal is visible on both sides. */
1531
+ refused(report: Refusal): void;
1514
1532
  /** Nothing may be allocated until a capacity is stated, so every connection receives it. */
1515
1533
  setCapacity(capacity: AgentCapacity): Promise<CapacityResult>;
1516
1534
  execute(request: TaskCommandRequest<C>): Promise<TaskCommandResult>;
package/dist/testing.js CHANGED
@@ -292,6 +292,19 @@ export async function exerciseAdapter(adapter, context, options = {}) {
292
292
  const connected = { ...context, host, store: watched.store };
293
293
  connection = await adapter.connect(connected);
294
294
  const live = connection;
295
+ // A refusal is visible on both sides: what the host would not take, the adapter is told, with the rules.
296
+ requireMethod(live, "refused", "every connection is told what the host refused");
297
+ const tellRefused = (report) => {
298
+ if (typeof live.refused !== "function")
299
+ return;
300
+ ruleEvaluated("connection.refused.rejected");
301
+ try {
302
+ live.refused(report);
303
+ }
304
+ catch (error) {
305
+ violations.push({ rule: "connection.refused.rejected", path: "connection.refused", message: `told of a refusal, the adapter threw: ${String(error)}` });
306
+ }
307
+ };
295
308
  // Dial is declared by presence: the capability object carries a destination policy rather
296
309
  // than an `enabled` flag, so its presence is the declaration.
297
310
  if (adapter.manifest.idleCapabilities?.dial !== undefined)
@@ -310,7 +323,10 @@ export async function exerciseAdapter(adapter, context, options = {}) {
310
323
  const waiters = new Set();
311
324
  unsubscribe = connection.subscribe(envelope => {
312
325
  observeEvent(envelope, seen);
313
- violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader()));
326
+ const broken = validateEventEnvelope(envelope, adapter.manifest, "event", reader());
327
+ violations.push(...broken);
328
+ if (broken.length > 0)
329
+ tellRefused({ artefact: "event", envelopeId: typeof envelope?.id === "string" ? envelope.id : undefined, violations: broken });
314
330
  // A diagnostic is informational to a host and a failure to a conformance run: the platform
315
331
  // under test broke a rule the adapter relies on, and a green result must not paper over it.
316
332
  // A reload in life has no first connection: the page is gone. The drive keeps its first
@@ -361,7 +377,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
361
377
  });
362
378
  const snapshot = await connection.snapshot();
363
379
  observeSnapshot(snapshot, seen);
364
- violations.push(...validateSnapshot(snapshot, adapter.manifest, "snapshot", reader()));
380
+ {
381
+ const broken = validateSnapshot(snapshot, adapter.manifest, "snapshot", reader());
382
+ violations.push(...broken);
383
+ if (broken.length > 0)
384
+ tellRefused({ artefact: "snapshot", violations: broken });
385
+ }
365
386
  violations.push(...undeterminedTasks(Array.isArray(snapshot?.tasks) ? snapshot.tasks : [], "snapshot.tasks"));
366
387
  stream.seed(snapshot);
367
388
  breaks.seed(snapshot);
@@ -431,6 +452,26 @@ export async function exerciseAdapter(adapter, context, options = {}) {
431
452
  context: connected, secrets: authenticationSecrets, reader, rebuild: options.rebuild, held: watched.held, reloading,
432
453
  }));
433
454
  }
455
+ // Capacity supersedes rather than accumulates, so a decrease is as ordinary as an increase: the
456
+ // host raises it, lowers it, and takes it away, and the provider takes each as the ceiling it is.
457
+ // A provider whose ceiling can only rise passes a zero it special-cases and still cannot go from
458
+ // five to three, so the exercise moves the axis both ways. Zero is host-stopped: the agent's
459
+ // capacity is elsewhere, and the provider allocates nothing and refuses nothing for it. Any offer
460
+ // after a lower count is caught against it (stream.taskOffered.overCapacity).
461
+ ruleEvaluated("connection.setCapacity.lowered", "connection.setCapacity.zero");
462
+ for (const count of [2, 1, 0]) {
463
+ capacityStated = count;
464
+ const restated = await live.setCapacity({ count });
465
+ const shape = validateResult(restated, "setCapacity", "connection.setCapacity");
466
+ violations.push(...shape);
467
+ if (shape.length === 0 && restated.status === "failed") {
468
+ violations.push({ rule: count === 0 ? "connection.setCapacity.zero" : "connection.setCapacity.lowered", path: "connection.setCapacity",
469
+ message: count === 0
470
+ ? `the provider would not take a capacity of zero: ${restated.failure.code}; zero is host-stopped, the agent's capacity being elsewhere, and a provider allocates nothing and refuses nothing for it`
471
+ : `the provider would not take a capacity of ${count} after a higher one: ${restated.failure.code}; capacity supersedes, and a decrease is as ordinary as an increase` });
472
+ break;
473
+ }
474
+ }
434
475
  }
435
476
  finally {
436
477
  let clean = true;
@@ -64,6 +64,8 @@ export declare function validateEventEnvelope(envelope: unknown, manifest: unkno
64
64
  * phone the microphone is the phone's, and off voice there is none. Neither the value nor its
65
65
  * absence is inferred from what kind of application the host is.
66
66
  */
67
+ /** What the host states as capacity: a whole number, zero or more, zero being host-stopped. */
68
+ export declare function validateCapacity(capacity: unknown, path?: string): ProtocolViolation[];
67
69
  /** The login's store, which the host provides on every connection: three functions, and nothing else is a store. */
68
70
  export declare function validateLoginStore(store: unknown, path?: string): ProtocolViolation[];
69
71
  export declare function validateHostMute(mute: unknown, softphone: boolean, path?: string): ProtocolViolation[];
@@ -1700,6 +1700,16 @@ const HOST_MUTES = membersOf({ stream: true, station: true });
1700
1700
  * phone the microphone is the phone's, and off voice there is none. Neither the value nor its
1701
1701
  * absence is inferred from what kind of application the host is.
1702
1702
  */
1703
+ /** What the host states as capacity: a whole number, zero or more, zero being host-stopped. */
1704
+ export function validateCapacity(capacity, path = "capacity") {
1705
+ const into = new Collector();
1706
+ if (!isPlainObject(capacity)) {
1707
+ into.add("capacity.shape", path, "a capacity is an object carrying count");
1708
+ return into.violations;
1709
+ }
1710
+ into.require(typeof capacity.count === "number" && Number.isInteger(capacity.count) && capacity.count >= 0, "capacity.count", `${path}.count`, "count is a whole number, zero or more: how many tasks this provider may allocate at once, zero while the agent's capacity is elsewhere");
1711
+ return into.violations;
1712
+ }
1703
1713
  /** The login's store, which the host provides on every connection: three functions, and nothing else is a store. */
1704
1714
  export function validateLoginStore(store, path = "store") {
1705
1715
  const into = new Collector();
package/guide.md CHANGED
@@ -425,7 +425,7 @@ type Snapshot = {
425
425
  };
426
426
 
427
427
  type AgentCapacity = {
428
- count: number; // absolute ceiling, at least 1
428
+ count: number; // absolute ceiling, zero or more; zero is host-stopped
429
429
  };
430
430
 
431
431
  type CapacityResult =
@@ -1066,9 +1066,16 @@ adapter compiled against the browser lib.
1066
1066
  ### Adapter and connection
1067
1067
 
1068
1068
  ```ts
1069
+ type Refusal = {
1070
+ artefact: "snapshot" | "event";
1071
+ envelopeId?: string;
1072
+ violations: ProtocolViolation[];
1073
+ };
1074
+
1069
1075
  type Connection<C extends Channel = Channel> = {
1070
1076
  snapshot(): Snapshot<C> | Promise<Snapshot<C>>;
1071
1077
  subscribe(listener: (envelope: ProviderEventEnvelope<C>) => void): Unsubscribe;
1078
+ refused(report: Refusal): void;
1072
1079
  setCapacity(capacity: AgentCapacity): Promise<CapacityResult>;
1073
1080
  execute(request: TaskCommandRequest<C>): Promise<TaskCommandResult>;
1074
1081
  disconnect(): Promise<void>;
@@ -2028,6 +2035,7 @@ surface in one place, and what obliges an adapter to implement each one.
2028
2035
  | `snapshot()` | Always. |
2029
2036
  | `subscribe(listener)` | Always. |
2030
2037
  | `disconnect()` | Always. |
2038
+ | `refused(report)` | Always. The host tells the adapter what it would not take -- a snapshot it did not replace its state with, an event it dropped -- with every rule broken, so a refusal is visible on both sides. See **What the host does with what it refuses**. |
2031
2039
  | `setCapacity(capacity)` | Always. Nothing may be allocated until a capacity is stated, so there is no connection that does not receive it. |
2032
2040
  | `execute(request)` | Always. Every channel has commands no capability gates — see **Which commands need a capability**. |
2033
2041
  | `describeUsers(ids)` | The adapter publishes any `UserId`: on `ImposedBreak.by`, a roster, or `handlingHistory[].by`. Each `User` carries its `timeZone` where the provider knows it. |
@@ -2358,9 +2366,10 @@ Reaching the instant is not itself a transition: the provider reports what it di
2358
2366
 
2359
2367
  **Drop both fields when the phase moves.** A provider that builds the `in-progress` task by
2360
2368
  spreading the `preview` one carries `previewEndsAt` and `atDeadline` with it, and the host refuses
2361
- the update (`task.preview.deadline.unexpected`) while the provider's own state looks right: the
2362
- desk hears nothing and the provider sees nothing wrong. The task past preview has no deadline to
2363
- wait for, so it carries neither field.
2369
+ the update (`task.preview.deadline.unexpected`): the desk keeps the task as it last stood, and the
2370
+ provider is told through `refused` exactly which rule, so the state that looked right on its side
2371
+ is named on its side. The task past preview has no deadline to wait for, so it carries neither
2372
+ field.
2364
2373
 
2365
2374
  **A task is never its audio.** A voice task is the allocation: the call is offered when it is
2366
2375
  routed to the agent and accepted as its `acceptance` dictates, and its presence and phase follow
@@ -3361,9 +3370,10 @@ hold applied. A `held` result would repeat the discriminant that travelled with
3361
3370
 
3362
3371
  States how many tasks this provider may have allocated to the agent **at once**.
3363
3372
 
3364
- `count` is an absolute ceiling, not an increment and never less than 1. An agent's capacity is a
3365
- property of the agent, not of the moment: it is stated when the agent is set up and restated only
3366
- when it genuinely changes, which is a provisioning change rather than a task starting or ending.
3373
+ `count` is an absolute ceiling, not an increment, a whole number of zero or more
3374
+ (`capacity.count`). An agent's capacity is a property of the agent, not of the moment: it is
3375
+ stated when the agent is set up and restated only when it genuinely changes, which is a
3376
+ provisioning change rather than a task starting or ending -- with one exception below.
3367
3377
 
3368
3378
  **The provider counts its own outstanding tasks against it.** Allocate while you hold fewer than
3369
3379
  `count` tasks for this agent, and stop when you hold that many; when one of yours ends you have
@@ -3371,9 +3381,21 @@ room again and need no new signal to know it. Omni does not re-state capacity as
3371
3381
  go, and a provider that waits for it will stall.
3372
3382
 
3373
3383
  Your own tasks are the only ones you count. What the agent holds at other providers is not your
3374
- concern — Omni set `count` knowing it.
3375
-
3376
- Capacity supersedes rather than accumulates: the latest value is the ceiling.
3384
+ concern — Omni set `count` knowing it, and this is how: the agent is one person on several
3385
+ providers, and the host divides their capacity among them rather than telling each the whole. A
3386
+ provider that has none of it for now is told **`count: 0`, host-stopped**: allocate nothing, show
3387
+ the member as `on-task` on the roster, since the agent is working, and take the next count as any
3388
+ other when the host has capacity for this provider again. Zero is not a refusal to state, and a
3389
+ provider that answers it `failed` is named (`connection.setCapacity.zero`); it is the one restatement
3390
+ that follows work rather than provisioning.
3391
+
3392
+ Capacity supersedes rather than accumulates: the latest value is the ceiling, and a decrease is as
3393
+ ordinary as an increase. A provider whose ceiling can only rise -- one that keeps the highest count
3394
+ it was ever told, or returns early on a small one -- cannot be told to take less work, and a host
3395
+ taking capacity away is answered `applied` while the work keeps coming. The harness moves the axis
3396
+ both ways after the drive, two then one then nought, and names a provider that will not come down
3397
+ (`connection.setCapacity.lowered`, `connection.setCapacity.zero`); an offer after a lower count is
3398
+ caught against it (`stream.taskOffered.overCapacity`).
3377
3399
 
3378
3400
  **Capacity gates what the provider allocates, not what the agent starts.** A call placed from the
3379
3401
  idle dialpad arrives through `task-offered` like any other task, and a full agent does not forbid
@@ -3965,10 +3987,12 @@ anything to become available.
3965
3987
  | Omni commits a break | `commitBreak`. The break stops allocation, not the ceiling. |
3966
3988
  | Agent returns from break | `endBreak` |
3967
3989
 
3968
- **Stopping is a break, not a capacity of zero.** Capacity says how much this agent can carry at
3969
- once; a break says they are not working. Collapsing the two would leave a provider unable to tell
3970
- an agent at their limit from an agent who has gone to lunch, and only one of those needs a reason,
3971
- a decision and a return.
3990
+ **A break is not a capacity of zero, and neither is the reverse.** Capacity says how much of this
3991
+ agent this provider may carry; a break says they are not working at all. A provider told
3992
+ `count: 0` shows an agent whose capacity is elsewhere, `on-task`; a provider told a break shows
3993
+ `on-break`. Collapsing the two would leave a provider unable to tell an agent working on another
3994
+ provider from an agent who has gone to lunch, and only one of those needs a reason, a decision and
3995
+ a return.
3972
3996
 
3973
3997
  ### How the agent hears the call
3974
3998
 
@@ -4578,6 +4602,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
4578
4602
  | `validateHostReport(report)` | The host's own report as published to an adapter: `online`, and where there is audio, an input that is `available` with the microphone and `flowing`, or `unavailable` with a reason and the failure that says why, and an output that is `available` or `unavailable` with its failure. The harness validates whatever host a test hands the adapter; `stillHost(report)` builds one that never changes. |
4579
4603
  | `validateHostMute(mute, softphone)` | What the host's Mute does, stated on a softphone login and nowhere else: `stream` or `station` (`host.mute`), required where the host holds a microphone (`host.mute.required`) and refused where it does not (`host.mute.unexpected`). The harness holds `ConnectContext.host.mute` to it. |
4580
4604
  | `validateLoginStore(store)` | The login's store the host hands every connection: an object with `get`, `set` and `delete` (`store.shape`, `store.get`, `.set`, `.delete`). The harness holds `ConnectContext.store` to it. |
4605
+ | `validateCapacity(capacity)` | What the host states as capacity: a whole number of zero or more (`capacity.count`), zero being host-stopped. The harness states one on connect and two, one and zero after the drive, and names a provider that will not come down (`connection.setCapacity.lowered`) or take zero (`connection.setCapacity.zero`). |
4581
4606
  | `validateAuthenticationResult(result, method)` | What `start()` or `complete()` answered: a challenge or a rejection, a login or a rejection. A rejection's failure is held to its rules -- an `omni.` code the contract lists, and `omni.phone-not-permitted` never retryable, since the agent's station is configuration. `validateAuthenticationFailure(failure)` is the same check on a failure alone. |
4582
4607
  | `validateTaskCommand(command, task?)` | What a command needs to be issuable, against the task it names: its own shape -- a dial's `dialId`, a transfer's item, a remove naming exactly one person -- and, with the task, the capability the table above gates it on (`command.capability.<name>`, `.locked`), the phase it belongs to (`command.phase.*`, `command.phase.handling` for every control on the call or the conversation), and the state that has to stand: a consulted entry, a lead requested, somebody else still on the call (`command.conference.remove.alone`). A host validates before sending and an adapter before acting. |
4583
4608
  | `validateResult(result, method)` | What a connection method answered: the status it gives, a failure where the status says so and nowhere else, the failure's shape, and that an `omni.` code is one this contract names. |
@@ -4605,6 +4630,25 @@ and may be compiled against a different protocol version, so its output is untru
4605
4630
  Validating a snapshot before it replaces provider state is what stops a malformed task from
4606
4631
  reaching the workspace.
4607
4632
 
4633
+ ### What the host does with what it refuses
4634
+
4635
+ A refusal has an aftermath on the desk and a report to the provider, and both are stated.
4636
+
4637
+ - **A refused snapshot replaces nothing.** The host keeps the last state it took from this
4638
+ provider whole, and shows the provider as faulted -- the transport as it stands, and the words
4639
+ "last update refused" with the rule -- so the agent knows the view is standing still rather than
4640
+ believing it current. It does not adopt the good half of a snapshot: a task the agent is on would
4641
+ vanish from the desk while the call is up.
4642
+ - **A refused event is dropped and counted.** The state the host holds does not move for it. The
4643
+ provider is told, and may republish a corrected state or raise a `diagnostic`; the host retries
4644
+ nothing.
4645
+ - **The provider is told, every time.** `refused(report)` carries the artefact, the envelope id for
4646
+ an event, and every violation with its rule and path. An adapter logs it at error and treats it as
4647
+ its own defect until shown otherwise: what the host refused never reached the agent, and a
4648
+ provider that hears nothing runs a whole shift beside a frozen desk with nothing wrong on its
4649
+ side. The harness tells an adapter under test exactly as a host does
4650
+ (`connection.refused.required`, `connection.refused.rejected`).
4651
+
4608
4652
  ## Conformance helpers
4609
4653
 
4610
4654
  ### `exerciseAdapter(adapter, context, options?)`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.68",
3
+ "version": "0.1.70",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",