@xema/omni-protocol 0.1.74 → 0.1.76

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
@@ -904,11 +904,14 @@ export type TaskOutcome = {
904
904
  } | {
905
905
  type: "transferred";
906
906
  destinationId?: string;
907
- } | {
907
+ }
908
+ /** Who called the work off: the agent declining, the provider withdrawing or re-routing, the party abandoning. */
909
+ | {
908
910
  type: "cancelled";
911
+ by: "agent" | "provider" | "party";
909
912
  reason?: string;
910
913
  }
911
- /** Only the phases in which somebody is still being waited on can expire. */
914
+ /** Only the phases in which somebody is still being waited on can expire; an offer that lapses at `allocationExpiresAt` names `pending`. */
912
915
  | {
913
916
  type: "expired";
914
917
  phase: "pending" | "confirmed" | "preview";
package/dist/testing.d.ts CHANGED
@@ -57,6 +57,13 @@ export interface ExerciseAdapterOptions {
57
57
  * memory has nothing, and is named. Without it the store is required and never read.
58
58
  */
59
59
  rebuild?: () => Adapter<Channel>;
60
+ /**
61
+ * The values the queue locks on this login's tasks -- a party's number or email -- as the run's
62
+ * author knows them. A task whose party stands locked may carry none of them anywhere else
63
+ * (`task.locked.leak`). Required once any task locks a party field: a run that cannot ask the
64
+ * question is not a pass, and says so by throwing rather than passing.
65
+ */
66
+ lockedValues?: readonly string[];
60
67
  }
61
68
  /**
62
69
  * Adapter conformance exercise: validates the manifest, opens an authenticated session,
package/dist/testing.js CHANGED
@@ -163,6 +163,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
163
163
  const breaks = new BreakStream();
164
164
  let seeded = false;
165
165
  const duringRead = [];
166
+ let lockedPartySeen = false;
166
167
  const storedSecrets = new Map();
167
168
  const authenticationSecrets = {
168
169
  get: async (key) => storedSecrets.get(key),
@@ -204,6 +205,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
204
205
  capabilities: current().capabilities,
205
206
  loginId: context.loginId,
206
207
  autoAcceptTasks: context.autoAcceptTasks,
208
+ locked: options.lockedValues,
207
209
  });
208
210
  // The optional methods are optional only until something declares a need for them. Each
209
211
  // check pairs a method with the declaration that requires it, as the guide's Live-connection
@@ -341,6 +343,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
341
343
  message: `the provider reported a diagnostic: expected ${String(envelope.event.expected)}; observed ${String(envelope.event.observed)}` });
342
344
  }
343
345
  violations.push(...undeterminedTasks(eventTasks(envelope), "event"));
346
+ lockedPartySeen ||= eventTasks(envelope).some(locksParty);
344
347
  if (eventNamesUsers(envelope))
345
348
  requireMethod(live, "describeUsers", "an event publishes a UserId");
346
349
  // Work is pulled, never pushed: an offer before the host stated capacity is an allocation
@@ -427,6 +430,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
427
430
  tellRefused({ artefact: "snapshot", violations: broken });
428
431
  }
429
432
  violations.push(...undeterminedTasks(Array.isArray(snapshot?.tasks) ? snapshot.tasks : [], "snapshot.tasks"));
433
+ lockedPartySeen ||= (Array.isArray(snapshot?.tasks) ? snapshot.tasks : []).some(locksParty);
430
434
  stream.seed(snapshot);
431
435
  breaks.seed(snapshot);
432
436
  seeded = true;
@@ -587,6 +591,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
587
591
  message: "an unsubscribe, disconnect(), or close() threw during shutdown",
588
592
  });
589
593
  }
594
+ // A task locked a party field and the run was not told the value: task.locked.leak was never
595
+ // asked, and a result that cannot say is not returned as one that passed.
596
+ if (lockedPartySeen && options.lockedValues === undefined) {
597
+ stopObserving();
598
+ throw new TypeError("a task locks its party's number or email and the run states no lockedValues: task.locked.leak cannot be evaluated; pass the values the queue locked in options.lockedValues");
599
+ }
590
600
  if (!options.collectOnly)
591
601
  assertNoViolations(violations);
592
602
  stopObserving();
@@ -727,6 +737,13 @@ export function assertAuthenticationRestoreAndExpiry(states) {
727
737
  }
728
738
  }
729
739
  /** The tasks an envelope carries: the one a task event names, or a snapshot event's list. */
740
+ /** Whether a task's party stands locked on its number or email, which is what obliges a run to state the locked values. */
741
+ function locksParty(task) {
742
+ if (!isRecord(task) || !isRecord(task.party))
743
+ return false;
744
+ const locked = (value) => isRecord(value) && value.lockedBy !== undefined;
745
+ return locked(task.party.number) || locked(task.party.email);
746
+ }
730
747
  function eventTasks(envelope) {
731
748
  if (!isRecord(envelope) || !isRecord(envelope.event))
732
749
  return [];
@@ -1026,7 +1043,9 @@ export class TaskStream {
1026
1043
  stages.set(entry.dialId, entry.stage);
1027
1044
  }
1028
1045
  }
1029
- return { phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task), allocation: String(isRecord(task) ? task.allocationId : undefined) };
1046
+ return { channel: String(isRecord(task) ? task.channel : undefined), completionMode: String(isRecord(task) ? task.completionMode : undefined),
1047
+ wrapAllowance: isRecord(task) && typeof task.wrapAllowance === "number" ? task.wrapAllowance : undefined,
1048
+ phase: String(isRecord(task) ? task.phase : undefined), media, source: String(isRecord(task) ? task.capabilitySource : undefined), stages, record: TaskStream.record(task), allocation: String(isRecord(task) ? task.allocationId : undefined) };
1030
1049
  }
1031
1050
  /** Replaces what is known with a snapshot's tasks, as a snapshot replaces Omni's state. */
1032
1051
  seed(snapshot) {
@@ -1214,6 +1233,15 @@ export class TaskStream {
1214
1233
  // A late ending for a life that is over must not end the life that is open under the same id.
1215
1234
  if (!this.namesTheOpenLife(event, known, at, refuse))
1216
1235
  break;
1236
+ // Off voice there is no media event, so the update that moves a task to completing is the
1237
+ // provider's word that handling ended and the moment the wrap allowance starts. A provider
1238
+ // that completes the task itself with an allowance to run has to have started the clock:
1239
+ // completed from in-progress, the allowance it stated was never given.
1240
+ ruleEvaluated("stream.taskEnded.unwrapped");
1241
+ if (known.channel !== "voice" && known.completionMode === "provider-automatic" && known.wrapAllowance !== undefined && known.wrapAllowance > 0
1242
+ && known.phase !== "completing" && isRecord(event.outcome) && event.outcome.type === "completed") {
1243
+ refuse("stream.taskEnded.unwrapped", `${at}.outcome`, `${id} was completed from ${known.phase} with a wrap allowance of ${known.wrapAllowance}s under provider-automatic: off voice, completing is the provider's word that handling ended and the allowance's start, and it was never published`);
1244
+ }
1217
1245
  this.endedAllocations.add(known.allocation);
1218
1246
  this.tasks.delete(id);
1219
1247
  break;
@@ -32,6 +32,12 @@ export interface TaskValidationContext {
32
32
  autoAcceptTasks?: boolean;
33
33
  /** Whether the manifest declares `dialOutcomes`. A task that may dial needs it to; absent, the question is not asked. */
34
34
  dialOutcomesDeclared?: boolean;
35
+ /**
36
+ * The values the queue locked on this login's tasks -- a party's number or email -- as whoever
37
+ * runs the validator knows them. Where a task's party stands locked, no other field of it may
38
+ * carry one of these. Unknown to a host, which never sees the value, and then unchecked.
39
+ */
40
+ locked?: readonly string[];
35
41
  }
36
42
  export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
37
43
  /**
@@ -53,6 +59,12 @@ export interface ReaderContext {
53
59
  levels?: readonly string[];
54
60
  /** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
55
61
  loginId?: string;
62
+ /**
63
+ * The values the queue locked on this login's tasks, as whoever runs the validator knows them:
64
+ * a conformance run states them, a host never has them. Where a task's party stands locked, no
65
+ * other field of it may carry one (`task.locked.leak`). Absent, unchecked.
66
+ */
67
+ locked?: readonly string[];
56
68
  /** `ConnectContext.autoAcceptTasks` as sent: whether a pending task states its `acceptance`. Unknown to a caller without the context, and then unchecked. */
57
69
  autoAcceptTasks?: boolean;
58
70
  }
@@ -89,6 +89,7 @@ const PHONES = membersOf({ softphone: true, deskPhone: true });
89
89
  const TEAM_CAPABILITIES = membersOf({ breakControl: true, leadAssistControl: true, policyControl: true, monitorControl: true });
90
90
  const MONITOR_MODES = membersOf({ monitor: true, whisper: true, barge: true });
91
91
  const COMPLETED_BY = membersOf({ agent: true, provider: true });
92
+ const CANCELLED_BY = membersOf({ agent: true, provider: true, party: true });
92
93
  const EXPIRABLE_PHASES = membersOf({
93
94
  pending: true, confirmed: true, preview: true,
94
95
  });
@@ -598,6 +599,53 @@ const PERSON_SETTABLE = /^(hold|skill:.+)$/;
598
599
  const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
599
600
  /** The level ids in force: the manifest's, or the defaults when the caller holds no manifest. */
600
601
  const levelIds = (levels) => levels ?? DEFAULT_LEVEL_IDS;
602
+ /**
603
+ * What the queue locks is locked on the whole task. Given the locked values, every other field an
604
+ * agent reads -- the title, the reference, an attribute, a custom control's label, a browser URL the
605
+ * desk shows -- is held to carrying none of them. A number is compared by its digits, so no
606
+ * formatting hides it; anything else by its text, case aside.
607
+ */
608
+ function validateNothingLeaksInto(task, locked, path, into) {
609
+ ruleEvaluated("task.locked.leak");
610
+ const carries = (text, value) => {
611
+ if (typeof text !== "string")
612
+ return false;
613
+ const digits = value.replace(/[\s+().-]/g, "");
614
+ if (/^\d+$/.test(digits))
615
+ return text.replace(/\D/g, "").includes(digits);
616
+ return text.toLowerCase().includes(value.trim().toLowerCase());
617
+ };
618
+ const hold = (text, at) => {
619
+ for (const value of locked) {
620
+ if (carries(text, value))
621
+ into.add("task.locked.leak", at, "carries a value the queue locked: what the queue locks is locked on the whole task, and appears nowhere the agent reads");
622
+ }
623
+ };
624
+ hold(task.title, `${path}.title`);
625
+ hold(task.reference, `${path}.reference`);
626
+ if (Array.isArray(task.attributes)) {
627
+ task.attributes.forEach((attribute, index) => {
628
+ if (!isPlainObject(attribute))
629
+ return;
630
+ hold(attribute.label, `${path}.attributes[${index}].label`);
631
+ hold(attribute.value, `${path}.attributes[${index}].value`);
632
+ if (isPlainObject(attribute.party)) {
633
+ hold(attribute.party.number, `${path}.attributes[${index}].party.number`);
634
+ hold(attribute.party.email, `${path}.attributes[${index}].party.email`);
635
+ }
636
+ });
637
+ }
638
+ const custom = isPlainObject(task.capabilities) ? task.capabilities.custom : undefined;
639
+ if (Array.isArray(custom))
640
+ custom.forEach((control, index) => { if (isPlainObject(control))
641
+ hold(control.label, `${path}.capabilities.custom[${index}].label`); });
642
+ if (Array.isArray(task.browsers)) {
643
+ task.browsers.forEach((browser, index) => {
644
+ if (isPlainObject(browser) && browser.urlVisibility !== "hidden")
645
+ hold(browser.url, `${path}.browsers[${index}].url`);
646
+ });
647
+ }
648
+ }
601
649
  /** `lockedBy`: a declared level other than `person`, who never locks their own value. */
602
650
  function validateLockedByInto(value, rule, path, levels, into) {
603
651
  if (!into.filled(value, rule, path, "lockedBy names the level that locked it"))
@@ -947,6 +995,12 @@ function validateMonitoring(value, channel, path, into) {
947
995
  into.oneOf(value.mode, MONITOR_MODES, "task.monitoring.mode", `${path}.mode`);
948
996
  into.timestamp(value.since, "task.monitoring.since", `${path}.since`);
949
997
  }
998
+ /** Whether a task is the agent's own doing: a dial the host placed on its onCall, a lead's join, a lead's monitor. */
999
+ function originatedByTheAgent(task) {
1000
+ if (Array.isArray(task.onCall) && task.onCall.some(entry => isPlainObject(entry) && typeof entry.dialId === "string"))
1001
+ return true;
1002
+ return task.assisting !== undefined || task.monitoring !== undefined;
1003
+ }
950
1004
  export function validateTask(task, context, path = "task") {
951
1005
  const into = new Collector();
952
1006
  validateTaskInto(task, context, path, into);
@@ -985,15 +1039,27 @@ function validateTaskInto(task, context, path, into) {
985
1039
  }
986
1040
  // Acceptance is the offer's word, carried on the pending task so a snapshot can say it: it
987
1041
  // travels exactly when Omni said tasks may be auto-accepted, and only while the task is pending.
1042
+ // Work the agent originated -- a dial or connect-back, carrying the host's dialId on onCall; a
1043
+ // lead's join, carrying assisting; a monitor, carrying monitoring -- was accepted by the command
1044
+ // that created it, and says so whatever the provisioning: the desk shows no Accept for a call
1045
+ // the agent placed. The provisioning governs work the queue routes, and nothing else.
1046
+ const originated = task.phase === "pending" && originatedByTheAgent(task);
1047
+ ruleEvaluated("task.acceptance.originated");
988
1048
  if (task.acceptance !== undefined) {
989
1049
  into.oneOf(task.acceptance, ACCEPTANCE_MODES, "task.acceptance", `${path}.acceptance`);
990
1050
  if (task.phase !== "pending") {
991
1051
  into.add("task.acceptance.unexpected", `${path}.acceptance`, "acceptance is an offer's word; a task past pending has been accepted");
992
1052
  }
1053
+ else if (originated) {
1054
+ into.require(task.acceptance === "automatic", "task.acceptance.originated", `${path}.acceptance`, "work the agent originated -- a dial, a connect-back, a join, a monitor -- was accepted by the command that created it, and says automatic whatever the provisioning");
1055
+ }
993
1056
  else if (context.autoAcceptTasks === false) {
994
- into.add("task.acceptance.unexpected", `${path}.acceptance`, "autoAcceptTasks is off, so every task requires agent acceptance and a pending task carries no acceptance");
1057
+ into.add("task.acceptance.unexpected", `${path}.acceptance`, "autoAcceptTasks is off, so every task the queue routes requires agent acceptance and a pending task carries no acceptance");
995
1058
  }
996
1059
  }
1060
+ else if (originated) {
1061
+ into.add("task.acceptance.originated", `${path}.acceptance`, "work the agent originated -- a dial, a connect-back, a join, a monitor -- says acceptance: automatic whatever the provisioning: the command that created it accepted it");
1062
+ }
997
1063
  else if (task.phase === "pending" && context.autoAcceptTasks === true) {
998
1064
  into.add("task.acceptance.required", `${path}.acceptance`, "autoAcceptTasks is on, so a pending task states how it is accepted");
999
1065
  }
@@ -1015,6 +1081,9 @@ function validateTaskInto(task, context, path, into) {
1015
1081
  }
1016
1082
  if (task.party !== undefined)
1017
1083
  validateContactInto(task.party, `${path}.party`, into, context.levels);
1084
+ if (context.locked !== undefined && isPlainObject(task.party) && (isLocked(task.party.number) || isLocked(task.party.email))) {
1085
+ validateNothingLeaksInto(task, context.locked, path, into);
1086
+ }
1018
1087
  validateBrowsers(task.browsers, `${path}.browsers`, into);
1019
1088
  validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
1020
1089
  validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into, { phase: task.phase, media: task.media });
@@ -1308,7 +1377,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1308
1377
  let assisting;
1309
1378
  let monitoring;
1310
1379
  snapshot.tasks.forEach((task, index) => {
1311
- validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${path}.tasks[${index}]`, into);
1380
+ validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
1312
1381
  // A lead assists one call at a time, and listens to one at a time.
1313
1382
  if (isPlainObject(task) && task.assisting !== undefined) {
1314
1383
  if (assisting !== undefined)
@@ -1419,6 +1488,9 @@ function validateTaskOutcome(value, path, into) {
1419
1488
  }
1420
1489
  break;
1421
1490
  case "cancelled":
1491
+ // One word covered the agent declining, the provider withdrawing and the party abandoning the
1492
+ // ring; a record that cannot tell them apart is not a record. by says who, as completed does.
1493
+ into.oneOf(value.by, CANCELLED_BY, "event.taskEnded.outcome.cancelled.by", `${path}.by`);
1422
1494
  if (value.reason !== undefined) {
1423
1495
  into.filled(value.reason, "event.taskEnded.outcome.cancelled", `${path}.reason`, "a reason must not be empty when present");
1424
1496
  }
@@ -1520,7 +1592,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1520
1592
  validateBreakState(event.break, `${at}.break`, into);
1521
1593
  break;
1522
1594
  case "task-offered":
1523
- validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${at}.task`, into);
1595
+ validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
1524
1596
  // An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
1525
1597
  if (isPlainObject(event.task) && typeof event.task.phase === "string") {
1526
1598
  into.require(OFFERABLE_PHASES.includes(event.task.phase), "event.taskOffered.phase", `${at}.task.phase`, `task-offered introduces a task as ${OFFERABLE_PHASES.join(", ")}, never as ${event.task.phase}`);
@@ -1529,7 +1601,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1529
1601
  into.timestamp(event.allocationExpiresAt, "event.taskOffered.allocationExpiresAt", `${at}.allocationExpiresAt`);
1530
1602
  break;
1531
1603
  case "task-updated":
1532
- validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${at}.task`, into);
1604
+ validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
1533
1605
  break;
1534
1606
  case "task-media-started":
1535
1607
  into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
package/guide.md CHANGED
@@ -702,7 +702,7 @@ type AcceptanceMode =
702
702
  type TaskOutcome =
703
703
  | { type: "completed"; by: "agent" | "provider" }
704
704
  | { type: "transferred"; destinationId?: string }
705
- | { type: "cancelled"; reason?: string }
705
+ | { type: "cancelled"; by: "agent" | "provider" | "party"; reason?: string }
706
706
  | { type: "expired"; phase: "pending" | "confirmed" | "preview" }
707
707
  | { type: "left" }
708
708
  | { type: "failed"; failure: ProtocolFailure };
@@ -831,7 +831,14 @@ lock, so nothing that can be locked — a directory, a number — may carry that
831
831
  contact's number and email are the same, since each identifies a person: where the queue says
832
832
  the agent may not see it, the provider sends `{ lockedBy }` in its place — the last digits or
833
833
  nothing — and a CRM link carries a token, never the value with a flag the desk is asked to honour.
834
- A name is not locked. What the queue provides rather than permits — browsers, dispositions, custom
834
+ **What the queue locks is locked on the whole task.** A number the agent may not see appears
835
+ nowhere else they read — not in the title, the reference, an attribute, a custom control's label,
836
+ or a browser URL the desk shows; a browser whose URL must carry it says `urlVisibility: "hidden"`.
837
+ The adapter is the one that knows the value, so it is the one held to it: given the values the
838
+ queue locked, the validator refuses any other field carrying one, digits compared as digits so no
839
+ formatting hides them (`task.locked.leak`), and a conformance run whose tasks lock a party's number
840
+ or email states those values in `lockedValues`, since a run that cannot ask the question is not a
841
+ pass. A host never has the value and never asks. A name is not locked. What the queue provides rather than permits — browsers, dispositions, custom
835
842
  controls — is content, and is never locked.
836
843
 
837
844
  **A lead sets the team's policy from their roster.** A login that declares
@@ -2163,8 +2170,8 @@ person's behalf for want of a word:
2163
2170
  | `consent` | The provider requires the person's explicit consent: Omni presents **Accept** and waits, whatever its own policy would have done. A host that declares `guarantees.personConsent` promises exactly this; a provider checks it before offering work only a person may take. |
2164
2171
  | `automatic` | Omni accepts immediately without agent interaction. |
2165
2172
 
2166
- When Omni sent `autoAcceptTasks: false`, the provider omits `acceptance` and every task
2167
- requires agent acceptance. The two are never confused on the wire: `consent` is always the
2173
+ When Omni sent `autoAcceptTasks: false`, the provider omits `acceptance` and every task the
2174
+ queue routes requires agent acceptance. The two are never confused on the wire: `consent` is always the
2168
2175
  provider's requirement, stated on a wire where Omni was willing to accept for the agent; Omni's own
2169
2176
  no-auto-accept policy puts no word on the wire at all — the field is absent, and the **Accept**
2170
2177
  press is Omni's doing, not the provider's.
@@ -2178,8 +2185,13 @@ belongs, whatever the host was configured with.
2178
2185
 
2179
2186
  An automatically accepted task still arrives through `task-offered`.
2180
2187
 
2181
- Agent-initiated work arrives through `task-offered` with the task's
2182
- `acceptance: "automatic"`.
2188
+ **Work the agent originated is accepted by the command that created it.** A task born of the
2189
+ agent's own act — a dialpad call or a connect-back, recognisable by the host's `dialId` on
2190
+ `onCall`; a lead's join, carrying `assisting`; a lead's monitor, carrying `monitoring` — arrives
2191
+ through `task-offered` with `acceptance: "automatic"` whatever `autoAcceptTasks` says, and the
2192
+ validator holds it there under either provisioning and under none (`task.acceptance.originated`):
2193
+ the desk shows no **Accept** for a call the agent placed. The provisioning governs work the queue
2194
+ routes to the agent, and nothing else.
2183
2195
 
2184
2196
  ### Pending
2185
2197
 
@@ -2213,14 +2225,20 @@ on a desk phone — and is not in question per call. See **How the agent hears t
2213
2225
  Automatic acceptance still begins with `task-offered`.
2214
2226
 
2215
2227
  `allocationExpiresAt` is the deadline after which the offer lapses. Where present, Omni counts
2216
- down and stops offering **Accept** once it passes. **Omit it unless the provider can observe it.**
2228
+ down and stops offering **Accept** once it passes; the provider ends the lapsed offer with
2229
+ `task-ended` and an `expired` outcome naming `pending`, since nobody cancelled it. **Omit it unless the provider can observe it.**
2217
2230
  A provider that reports only elapsed ring time after the fact cannot say when an offer is due
2218
2231
  to end, and a computed value would have Omni withdraw **Accept** from a task still pending.
2219
2232
 
2220
2233
  A preview's deadline is not on the offer. It travels on the task, as `previewEndsAt` with
2221
2234
  `atDeadline`, so a snapshot carries it too -- see **Preview: the agent presses Call**.
2222
2235
 
2223
- A provider may withdraw a pending task by emitting `task-ended` with a `cancelled` outcome.
2236
+ A provider may withdraw a pending task by emitting `task-ended` with a `cancelled` outcome, `by:
2237
+ "provider"`. **`cancelled` says who called the work off**, as `completed` says who completed it:
2238
+ `agent` for a decline, `provider` for a withdrawal or a re-route, `party` for a caller who abandoned
2239
+ the ring. One word for the three left a supervisor's record unable to tell them apart, so `by` is
2240
+ required and closed (`event.taskEnded.outcome.cancelled.by`). An offer nobody acted on before
2241
+ `allocationExpiresAt` is not cancelled by anyone: it lapses, and ends `expired` naming `pending`.
2224
2242
 
2225
2243
  ### Tasks already in progress
2226
2244
 
@@ -2302,7 +2320,8 @@ The canonical task transitions are:
2302
2320
  | `preview` | Agent presses Call (`call`) and the customer answers, or the deadline `calls` and they answer | `in-progress` |
2303
2321
  | `preview` | The call goes out and nobody answers | `completing` |
2304
2322
  | `preview` | The deadline `expires` | Removed by `task-ended` with `expired` outcome |
2305
- | `pending` | Provider withdraws the allocation | Removed by `task-ended` with `cancelled` outcome |
2323
+ | `pending` | Provider withdraws the allocation, the agent declines, or the party abandons the ring | Removed by `task-ended` with `cancelled` outcome, `by` saying which |
2324
+ | `pending` | The offer lapses at `allocationExpiresAt` | Removed by `task-ended` with `expired` outcome naming `pending` |
2306
2325
  | No task | Snapshot reports work already underway | `in-progress` |
2307
2326
  | `in-progress` | Provider or agent pauses the task | `paused` |
2308
2327
  | `in-progress` | Agent starts a warm transfer (`transfer` `warm`); the customer is parked | `paused` |
@@ -2413,9 +2432,18 @@ whether the channel carries real-time media:
2413
2432
  | Channel | Wrap allowance starts at |
2414
2433
  | --- | --- |
2415
2434
  | Voice and any channel with real-time media | The `task-media-ended` event |
2416
- | Chat | When the conversation ends and the task enters `completing` |
2417
- | Email | After the message is sent and the task enters `completing` |
2418
- | Other non-media channels | The moment the task enters `completing` |
2435
+ | Chat | The `task-updated` that moves the task to `completing`: the provider's word that the conversation is closed on its side, the allowance running from that publication's `occurredAt` |
2436
+ | Email | The `task-updated` that moves the task to `completing`: the provider's word that the platform has accepted the outgoing message, the allowance running from that publication's `occurredAt` |
2437
+ | Other non-media channels | The `task-updated` that moves the task to `completing` |
2438
+
2439
+ **Off voice, `completing` is the provider's word that handling ended.** There is no media event to
2440
+ carry it, so the phase does; a host starts the wrap clock at that publication and nowhere else. It
2441
+ is optional: a conversation with nothing to wrap moves from `in-progress` to `task-ended` and
2442
+ `completing` is never published. Under `provider-automatic` with a non-zero `wrapAllowance` it is
2443
+ required, because the clock has to start somewhere: a chat or email task the provider completes
2444
+ from `in-progress` with an allowance to run gave the agent none of it, and the stream refuses the
2445
+ ending (`stream.taskEnded.unwrapped`). Under `agent-command` the agent's `complete` is the end,
2446
+ from `in-progress` or from `completing` alike.
2419
2447
 
2420
2448
  ```ts
2421
2449
  const emailCompletion = {
@@ -4261,8 +4289,8 @@ beside the mapped one is what the full check costs a host; an adapter has it for
4261
4289
  validator holds a command only to a task that stands: a task handed in that is not one the wire
4262
4290
  published is named (`command.task`) rather than checked against.
4263
4291
 
4264
- Declining or rejecting a pending offer ends it without accepting or completing it. The provider
4265
- confirms the end with `task-ended` and a `cancelled` outcome.
4292
+ Declining a pending offer ends it without accepting or completing it. The provider confirms the
4293
+ end with `task-ended` and a `cancelled` outcome, `by: "agent"`.
4266
4294
 
4267
4295
  ### `execute(request)`
4268
4296
 
@@ -4624,7 +4652,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
4624
4652
  | Function | Validates |
4625
4653
  | --- | --- |
4626
4654
  | `validateManifest(manifest)` | Identity, protocol-version interoperability, authentication methods, and idle-capability shapes. |
4627
- | `validateTask(task, { channel })` | Identity, channel agreement, phase, wrap allowance, capability shapes, custom controls, and browsers. |
4655
+ | `validateTask(task, { channel, locked? })` | Identity, channel agreement, phase, wrap allowance, capability shapes, custom controls, and browsers. Given `locked`, the values the queue locked, a task whose party stands locked carries none of them anywhere else (`task.locked.leak`). |
4628
4656
  | `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team roster, the stated `taskCount` reconciled against the tasks carried, and every task, contact, and activity, including idle-capability gating both ways: a contribution the manifest never declared is refused, and one it declares is required, `[]` included. |
4629
4657
  | `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
4630
4658
  | `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",