@xema/omni-protocol 0.1.73 → 0.1.75

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/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,
@@ -133,6 +140,11 @@ export declare function assertReconnectWithMissedAssignments<C extends Channel>(
133
140
  * both.
134
141
  */
135
142
  export declare function assertDeniedAndRetriedBreak(approvals: readonly BreakApproval[]): void;
143
+ /**
144
+ * The event kinds a snapshot restates, and so supersedes when delivered while it is read. Every
145
+ * other kind reports a transaction no snapshot carries, and a host applies it after the snapshot.
146
+ */
147
+ export declare const SUPERSEDED_BY_A_SNAPSHOT: ReadonlySet<string>;
136
148
  export declare class TaskStream {
137
149
  private readonly tasks;
138
150
  private readonly allocations;
package/dist/testing.js CHANGED
@@ -162,6 +162,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
162
162
  const stream = new TaskStream();
163
163
  const breaks = new BreakStream();
164
164
  let seeded = false;
165
+ const duringRead = [];
166
+ let lockedPartySeen = false;
165
167
  const storedSecrets = new Map();
166
168
  const authenticationSecrets = {
167
169
  get: async (key) => storedSecrets.get(key),
@@ -203,6 +205,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
203
205
  capabilities: current().capabilities,
204
206
  loginId: context.loginId,
205
207
  autoAcceptTasks: context.autoAcceptTasks,
208
+ locked: options.lockedValues,
206
209
  });
207
210
  // The optional methods are optional only until something declares a need for them. Each
208
211
  // check pairs a method with the declaration that requires it, as the guide's Live-connection
@@ -340,6 +343,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
340
343
  message: `the provider reported a diagnostic: expected ${String(envelope.event.expected)}; observed ${String(envelope.event.observed)}` });
341
344
  }
342
345
  violations.push(...undeterminedTasks(eventTasks(envelope), "event"));
346
+ lockedPartySeen ||= eventTasks(envelope).some(locksParty);
343
347
  if (eventNamesUsers(envelope))
344
348
  requireMethod(live, "describeUsers", "an event publishes a UserId");
345
349
  // Work is pulled, never pushed: an offer before the host stated capacity is an allocation
@@ -369,9 +373,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
369
373
  }
370
374
  eventPayloads.set(envelope.id, payload);
371
375
  }
372
- // Cross-event rules apply once the stream has a beginning: the connect snapshot.
376
+ // Cross-event rules apply once the stream has a beginning: the connect snapshot. An event
377
+ // delivered while that snapshot is read is held until it lands, and read then against it.
373
378
  if (seeded)
374
379
  violations.push(...stream.apply(envelope), ...breaks.apply(envelope));
380
+ else
381
+ duringRead.push(envelope);
375
382
  events.push(envelope);
376
383
  for (const waiter of waiters)
377
384
  waiter(envelope);
@@ -423,9 +430,36 @@ export async function exerciseAdapter(adapter, context, options = {}) {
423
430
  tellRefused({ artefact: "snapshot", violations: broken });
424
431
  }
425
432
  violations.push(...undeterminedTasks(Array.isArray(snapshot?.tasks) ? snapshot.tasks : [], "snapshot.tasks"));
433
+ lockedPartySeen ||= (Array.isArray(snapshot?.tasks) ? snapshot.tasks : []).some(locksParty);
426
434
  stream.seed(snapshot);
427
435
  breaks.seed(snapshot);
428
436
  seeded = true;
437
+ // A snapshot supersedes what it restates and nothing else. Of the events held during the read,
438
+ // a state-replacing kind is dropped, since the snapshot carries that state and must account for
439
+ // it; the rest -- a dial's outcome, a diagnostic, an announcement, a queue summary -- report
440
+ // transactions no snapshot carries, and are applied after it, in order.
441
+ ruleEvaluated("snapshot.accounts.task", "snapshot.accounts.ended");
442
+ const carried = new Set();
443
+ for (const task of Array.isArray(snapshot?.tasks) ? snapshot.tasks : []) {
444
+ if (isRecord(task) && typeof task.allocationId === "string")
445
+ carried.add(task.allocationId);
446
+ }
447
+ for (const held of duringRead) {
448
+ const event = held.event;
449
+ if (!SUPERSEDED_BY_A_SNAPSHOT.has(String(event.type))) {
450
+ violations.push(...stream.apply(held), ...breaks.apply(held));
451
+ continue;
452
+ }
453
+ if ((event.type === "task-offered" || event.type === "task-updated") && isRecord(event.task) && typeof event.task.allocationId === "string" && !carried.has(event.task.allocationId)) {
454
+ violations.push({ rule: "snapshot.accounts.task", path: "snapshot.tasks",
455
+ message: `${String(event.task.id)} (${event.task.allocationId}) was published while the snapshot was read and the snapshot does not carry it: a snapshot accounts for everything the adapter emitted before it resolved` });
456
+ }
457
+ if (event.type === "task-ended" && typeof event.allocationId === "string" && carried.has(event.allocationId)) {
458
+ violations.push({ rule: "snapshot.accounts.ended", path: "snapshot.tasks",
459
+ message: `${String(event.taskId)} (${event.allocationId}) ended while the snapshot was read and the snapshot still carries it: a snapshot accounts for everything the adapter emitted before it resolved` });
460
+ }
461
+ }
462
+ duringRead.length = 0;
429
463
  requireCapabilityMethods(live, current().capabilities);
430
464
  if (publishesUserIds(snapshot)) {
431
465
  requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
@@ -557,6 +591,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
557
591
  message: "an unsubscribe, disconnect(), or close() threw during shutdown",
558
592
  });
559
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
+ }
560
600
  if (!options.collectOnly)
561
601
  assertNoViolations(violations);
562
602
  stopObserving();
@@ -697,6 +737,13 @@ export function assertAuthenticationRestoreAndExpiry(states) {
697
737
  }
698
738
  }
699
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
+ }
700
747
  function eventTasks(envelope) {
701
748
  if (!isRecord(envelope) || !isRecord(envelope.event))
702
749
  return [];
@@ -905,6 +952,14 @@ const REACHABLE_PHASES = {
905
952
  paused: new Set(["paused", "in-progress", "completing"]),
906
953
  completing: new Set(["completing"]),
907
954
  };
955
+ /**
956
+ * The event kinds a snapshot restates, and so supersedes when delivered while it is read. Every
957
+ * other kind reports a transaction no snapshot carries, and a host applies it after the snapshot.
958
+ */
959
+ export const SUPERSEDED_BY_A_SNAPSHOT = new Set([
960
+ "snapshot", "transport-status", "break-state", "task-offered", "task-updated", "task-ended",
961
+ "task-media-started", "task-media-ended", "team-updated", "contacts-updated", "calendar-updated",
962
+ ]);
908
963
  export class TaskStream {
909
964
  tasks = new Map();
910
965
  // Every allocation this login has seen, and the ones whose task has ended: an offer never reuses
@@ -1465,46 +1520,47 @@ async function driveOneCall(drive) {
1465
1520
  // 3b. The microphone is the host's. With the audio open, the drive mutes it for a moment and
1466
1521
  // reports the leg the provider's record would otherwise miss -- begun, then ended -- and
1467
1522
  // expects each report recorded. If the provider restates the task's record afterwards, the leg is in it.
1468
- let mutedLeg;
1469
- if (session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function") {
1523
+ const reportedLegs = [];
1524
+ const canRecordMute = session !== undefined && typeof session.setMuted === "function" && typeof drive.connection.recordStep === "function";
1525
+ const report = async (body) => {
1526
+ ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
1527
+ // The drive holds its own report to the contract before it crosses, as a host must.
1528
+ const leg = { taskId, allocationId: allocationOf(), step: "muted", mutedBy: "host", ...body };
1529
+ const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
1530
+ found.push(...own);
1531
+ if (own.length > 0)
1532
+ return;
1533
+ let answer;
1534
+ try {
1535
+ answer = await drive.connection.recordStep(leg);
1536
+ }
1537
+ catch (error) {
1538
+ refuse("drive.recordStep.rejected", "drive.recordStep", `recordStep rejected rather than answered: ${String(error)}`);
1539
+ return;
1540
+ }
1541
+ found.push(...validateResult(answer, "recordStep", "drive.recordStep.result"));
1542
+ if (isRecord(answer) && answer.status === "failed") {
1543
+ refuse("drive.recordStep.failed", "drive.recordStep", `the provider refused to record the host's muted leg: ${String(isRecord(answer.failure) ? answer.failure.code : answer.failure)}`);
1544
+ }
1545
+ };
1546
+ const setMuted = (muted) => {
1547
+ try {
1548
+ session.setMuted(muted);
1549
+ }
1550
+ catch {
1551
+ refuse("drive.openMedia.setMuted", "drive.openMedia", `the media session threw on setMuted(${String(muted)})`);
1552
+ }
1553
+ };
1554
+ if (canRecordMute) {
1470
1555
  const at = new Date().toISOString();
1471
- const report = async (body) => {
1472
- ruleEvaluated("drive.recordStep.rejected", "drive.recordStep.failed");
1473
- // The drive holds its own report to the contract before it crosses, as a host must.
1474
- const leg = { taskId, allocationId: allocationOf(), step: "muted", at, mutedBy: "host", ...body };
1475
- const own = validateHandlingReport(leg, "drive.recordStep.report", drive.manifest);
1476
- found.push(...own);
1477
- if (own.length > 0)
1478
- return;
1479
- let answer;
1480
- try {
1481
- answer = await drive.connection.recordStep(leg);
1482
- }
1483
- catch (error) {
1484
- refuse("drive.recordStep.rejected", "drive.recordStep", `recordStep rejected rather than answered: ${String(error)}`);
1485
- return;
1486
- }
1487
- found.push(...validateResult(answer, "recordStep", "drive.recordStep.result"));
1488
- if (isRecord(answer) && answer.status === "failed") {
1489
- refuse("drive.recordStep.failed", "drive.recordStep", `the provider refused to record the host's muted leg: ${String(isRecord(answer.failure) ? answer.failure.code : answer.failure)}`);
1490
- }
1491
- };
1492
- const setMuted = (muted) => {
1493
- try {
1494
- session.setMuted(muted);
1495
- }
1496
- catch {
1497
- refuse("drive.openMedia.setMuted", "drive.openMedia", `the media session threw on setMuted(${String(muted)})`);
1498
- }
1499
- };
1500
1556
  const began = Date.now();
1501
1557
  setMuted(true);
1502
- await report({});
1558
+ await report({ at });
1503
1559
  // A leg's duration is whole seconds and a leg shorter than one cannot be stated, so the mute holds for one.
1504
1560
  await new Promise(resolve => setTimeout(resolve, 1000));
1505
1561
  setMuted(false);
1506
- await report({ seconds: Math.round((Date.now() - began) / 1000), ended: true });
1507
- mutedLeg = { at, task: latestTask() };
1562
+ await report({ at, seconds: Math.round((Date.now() - began) / 1000), ended: true });
1563
+ reportedLegs.push({ at, task: latestTask() });
1508
1564
  // 3c. A host reload destroys the adapter object and keeps the login's store. Built again from
1509
1565
  // the same login, a second adapter carries the task and the leg -- from its platform, or from
1510
1566
  // the store -- or it composed the record in memory and the record died with it.
@@ -1525,8 +1581,18 @@ async function driveOneCall(drive) {
1525
1581
  }
1526
1582
  }
1527
1583
  }
1528
- // 5. End the call, where the agent may.
1584
+ // 5. End the call, where the agent may -- with the microphone muted, as agents do. The leg left
1585
+ // open at media end is the provider's to close, since the provider knows the instant the media
1586
+ // ended; the host's own closing report can only follow what it hears, is answered recorded, and
1587
+ // changes nothing. So the drive mutes, does not report the end before end-call, and reports it
1588
+ // after the media has ended.
1529
1589
  if (drive.channel === "voice" && latestTask().phase === "in-progress" && offers("endCall")) {
1590
+ let openLeg;
1591
+ if (canRecordMute) {
1592
+ openLeg = { at: new Date().toISOString(), began: Date.now() };
1593
+ setMuted(true);
1594
+ await report({ at: openLeg.at });
1595
+ }
1530
1596
  if (await send({ type: "end-call" }) !== undefined) {
1531
1597
  // A task that completes with its audio still up is refused by the stream as it passes
1532
1598
  // (stream.taskUpdated.mediaOpen); the drive does not also wait out the clock for an ending
@@ -1541,6 +1607,12 @@ async function driveOneCall(drive) {
1541
1607
  }, cursor);
1542
1608
  if (mediaEnded?.found === "ended")
1543
1609
  cursor = mediaEnded.at;
1610
+ if (openLeg !== undefined) {
1611
+ setMuted(false);
1612
+ // Stated at the record's grain: a leg that rounds to nought is 1.
1613
+ await report({ at: openLeg.at, seconds: Math.max(1, Math.round((Date.now() - openLeg.began) / 1000)), ended: true });
1614
+ reportedLegs.push({ at: openLeg.at, task: latestTask() });
1615
+ }
1544
1616
  if (await updated(t => t.phase === "completing", "the task completing after its media ended") !== undefined && offers("hold")) {
1545
1617
  await holdRefusedOutsideHandling();
1546
1618
  }
@@ -1593,11 +1665,13 @@ async function driveOneCall(drive) {
1593
1665
  if (await ended() !== undefined)
1594
1666
  await storeReleased();
1595
1667
  }
1596
- // The record kept by the provider has the leg the host reported, wherever the provider restated it.
1597
- if (mutedLeg !== undefined && latestTask() !== mutedLeg.task) {
1668
+ // The record kept by the provider has every leg the host reported, wherever the provider restated it.
1669
+ for (const reported of reportedLegs) {
1670
+ if (latestTask() === reported.task)
1671
+ continue;
1598
1672
  const history = latestTask().handlingHistory;
1599
1673
  if (isRecord(history) && Array.isArray(history.steps)) {
1600
- const { at } = mutedLeg;
1674
+ const { at } = reported;
1601
1675
  const leg = history.steps.find(entry => isRecord(entry) && entry.step === "muted" && entry.at === at);
1602
1676
  if (leg === undefined) {
1603
1677
  refuse("drive.recordStep.history", "drive.recordStep", `the provider restated the task's record after the host reported a muted leg at ${at}, and the leg is not in it`);
@@ -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
  }
@@ -598,6 +598,53 @@ const PERSON_SETTABLE = /^(hold|skill:.+)$/;
598
598
  const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
599
599
  /** The level ids in force: the manifest's, or the defaults when the caller holds no manifest. */
600
600
  const levelIds = (levels) => levels ?? DEFAULT_LEVEL_IDS;
601
+ /**
602
+ * What the queue locks is locked on the whole task. Given the locked values, every other field an
603
+ * agent reads -- the title, the reference, an attribute, a custom control's label, a browser URL the
604
+ * desk shows -- is held to carrying none of them. A number is compared by its digits, so no
605
+ * formatting hides it; anything else by its text, case aside.
606
+ */
607
+ function validateNothingLeaksInto(task, locked, path, into) {
608
+ ruleEvaluated("task.locked.leak");
609
+ const carries = (text, value) => {
610
+ if (typeof text !== "string")
611
+ return false;
612
+ const digits = value.replace(/[\s+().-]/g, "");
613
+ if (/^\d+$/.test(digits))
614
+ return text.replace(/\D/g, "").includes(digits);
615
+ return text.toLowerCase().includes(value.trim().toLowerCase());
616
+ };
617
+ const hold = (text, at) => {
618
+ for (const value of locked) {
619
+ if (carries(text, value))
620
+ 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");
621
+ }
622
+ };
623
+ hold(task.title, `${path}.title`);
624
+ hold(task.reference, `${path}.reference`);
625
+ if (Array.isArray(task.attributes)) {
626
+ task.attributes.forEach((attribute, index) => {
627
+ if (!isPlainObject(attribute))
628
+ return;
629
+ hold(attribute.label, `${path}.attributes[${index}].label`);
630
+ hold(attribute.value, `${path}.attributes[${index}].value`);
631
+ if (isPlainObject(attribute.party)) {
632
+ hold(attribute.party.number, `${path}.attributes[${index}].party.number`);
633
+ hold(attribute.party.email, `${path}.attributes[${index}].party.email`);
634
+ }
635
+ });
636
+ }
637
+ const custom = isPlainObject(task.capabilities) ? task.capabilities.custom : undefined;
638
+ if (Array.isArray(custom))
639
+ custom.forEach((control, index) => { if (isPlainObject(control))
640
+ hold(control.label, `${path}.capabilities.custom[${index}].label`); });
641
+ if (Array.isArray(task.browsers)) {
642
+ task.browsers.forEach((browser, index) => {
643
+ if (isPlainObject(browser) && browser.urlVisibility !== "hidden")
644
+ hold(browser.url, `${path}.browsers[${index}].url`);
645
+ });
646
+ }
647
+ }
601
648
  /** `lockedBy`: a declared level other than `person`, who never locks their own value. */
602
649
  function validateLockedByInto(value, rule, path, levels, into) {
603
650
  if (!into.filled(value, rule, path, "lockedBy names the level that locked it"))
@@ -779,7 +826,7 @@ function validateHandlingHistory(value, path, into, task = {}) {
779
826
  into.require(task.phase === "paused", "task.handlingHistory.held.open", `${at}.seconds`, `a held entry without seconds is a hold still running, and the task is ${String(task.phase)}: the hold has ended, and its duration is stated`);
780
827
  }
781
828
  if (entry.step === "muted") {
782
- into.require(task.media !== "ended" && task.phase !== "completing", "task.handlingHistory.muted.open", `${at}.seconds`, "a muted entry without seconds is a mute still running, and the call is over: the host ends its legs when the media ends, and the duration is stated");
829
+ into.require(task.media !== "ended" && task.phase !== "completing", "task.handlingHistory.muted.open", `${at}.seconds`, "a muted entry without seconds is a mute still running, and the call is over: the provider closes every open leg at the instant the media ends, and the duration is stated");
783
830
  }
784
831
  }
785
832
  // A muted entry carries whose the silence was, as the host reported it; no other step has it.
@@ -1015,6 +1062,9 @@ function validateTaskInto(task, context, path, into) {
1015
1062
  }
1016
1063
  if (task.party !== undefined)
1017
1064
  validateContactInto(task.party, `${path}.party`, into, context.levels);
1065
+ if (context.locked !== undefined && isPlainObject(task.party) && (isLocked(task.party.number) || isLocked(task.party.email))) {
1066
+ validateNothingLeaksInto(task, context.locked, path, into);
1067
+ }
1018
1068
  validateBrowsers(task.browsers, `${path}.browsers`, into);
1019
1069
  validateTaskAttributes(task.attributes, `${path}.attributes`, into, context.levels);
1020
1070
  validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into, { phase: task.phase, media: task.media });
@@ -1308,7 +1358,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1308
1358
  let assisting;
1309
1359
  let monitoring;
1310
1360
  snapshot.tasks.forEach((task, index) => {
1311
- validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${path}.tasks[${index}]`, into);
1361
+ validateTaskInto(task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${path}.tasks[${index}]`, into);
1312
1362
  // A lead assists one call at a time, and listens to one at a time.
1313
1363
  if (isPlainObject(task) && task.assisting !== undefined) {
1314
1364
  if (assisting !== undefined)
@@ -1520,7 +1570,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1520
1570
  validateBreakState(event.break, `${at}.break`, into);
1521
1571
  break;
1522
1572
  case "task-offered":
1523
- validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${at}.task`, into);
1573
+ validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
1524
1574
  // An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
1525
1575
  if (isPlainObject(event.task) && typeof event.task.phase === "string") {
1526
1576
  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 +1579,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1529
1579
  into.timestamp(event.allocationExpiresAt, "event.taskOffered.allocationExpiresAt", `${at}.allocationExpiresAt`);
1530
1580
  break;
1531
1581
  case "task-updated":
1532
- validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest) }, `${at}.task`, into);
1582
+ validateTaskInto(event.task, { channel, levels, autoAcceptTasks: context.autoAcceptTasks, dialOutcomesDeclared: manifestDials(manifest), locked: context.locked }, `${at}.task`, into);
1533
1583
  break;
1534
1584
  case "task-media-started":
1535
1585
  into.require(isTaskId(event.taskId), "event.taskMediaStarted.taskId", `${at}.taskId`, "a task id is required");
package/guide.md CHANGED
@@ -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
@@ -2064,9 +2071,15 @@ report that: `granted` is a promise to honour a later commit.
2064
2071
 
2065
2072
  Returns the provider's complete authoritative state at one point in time.
2066
2073
 
2067
- - Omni registers `subscribe()` before awaiting the initial snapshot and discards anything delivered
2068
- while the snapshot is read, because the snapshot accounts for it. Events after it are applied in
2069
- order.
2074
+ - Omni registers `subscribe()` before awaiting the initial snapshot. An event delivered while the
2075
+ snapshot is read is held until it lands, and what happens to it then depends on what it is. A
2076
+ snapshot restates state, so an event of a state-replacing kind -- `task-offered`, `task-updated`,
2077
+ `task-ended`, `task-media-started`, `task-media-ended`, `break-state`, `team-updated`,
2078
+ `contacts-updated`, `calendar-updated`, `transport-status`, `snapshot` -- is dropped, because the
2079
+ snapshot accounts for it. An event that reports a transaction no snapshot carries --
2080
+ `dial-outcome`, `diagnostic`, `announcement`, `queue-summary` -- is applied after the snapshot,
2081
+ in order: a dial's outcome delivered during a resync still ends the dial, and **Every dial has an
2082
+ outcome** holds through a snapshot. Events after it are applied in order.
2070
2083
  - `tasks` must contain every task currently owned by this agent for this provider.
2071
2084
  - A snapshot replaces Omni's state for this provider; it is not a partial patch.
2072
2085
  - The adapter may return synchronously when it already holds current live values, or
@@ -2540,7 +2553,13 @@ only while its media is up, so a `held` entry without `seconds` on a task that i
2540
2553
  (`task.handlingHistory.held.open`, `.muted.open`). Whoever performs the leg closes it — the
2541
2554
  provider whose platform parks the caller, the host whose microphone it is — by restating the entry
2542
2555
  with its duration, and **an entry that cannot be closed is not written**: open for ever is a
2543
- plausible nought one level down from a total. There is no resumed step; a resume is the end of a
2556
+ plausible nought one level down from a total. **A leg still open when the media ends is closed by
2557
+ the provider**, at that instant, in the same publication that says the media ended: the provider
2558
+ is the one that knows the instant, and the host can only learn of it afterwards. Agents end calls
2559
+ muted, so the host's leg is routinely open at media end; the provider closes it with the duration
2560
+ from the leg's `at` to the media's end, and the host's own closing report for that leg, which
2561
+ follows what it hears, is answered `recorded` and changes nothing, the entry standing as the
2562
+ provider closed it. One publisher of the closed leg, and no order between them to get right. There is no resumed step; a resume is the end of a
2544
2563
  hold, at the entry's `at` plus its `seconds`, and nothing is lost by not naming it twice. The same goes for every step: two mutes are two
2545
2564
  `muted` entries, and a call that joined two queues -- a menu's, then this one -- has two `queued`
2546
2565
  entries, one per join. Order is enforced: an entry earlier than the one before it is refused
@@ -4347,9 +4366,13 @@ a backend that does not know the session yet — yields no snapshot, and the ada
4347
4366
  `connecting` holding what it holds. See **Snapshots establish state; events report transactions**.
4348
4367
 
4349
4368
  A snapshot must account for **everything the adapter has emitted before it resolves**, not merely
4350
- everything emitted when it was requested. Omni discards events buffered during the read on that
4351
- promise; an adapter that serves a stale snapshot and then lets an earlier event through will have
4352
- Omni apply state the snapshot already superseded.
4369
+ everything emitted when it was requested. Omni drops the state-replacing events held during the
4370
+ read on that promise, and applies the rest after it (see **`Connection.snapshot()`**); an adapter
4371
+ that serves a stale snapshot and then lets an earlier event through will have Omni apply state the
4372
+ snapshot already superseded. The harness reads its connect snapshot the same way, and holds the
4373
+ snapshot to accounting for every task event it superseded: a task published during the read and
4374
+ missing from the snapshot, or ended during the read and still carried, is named
4375
+ (`snapshot.accounts.task`, `snapshot.accounts.ended`).
4353
4376
 
4354
4377
  #### Liveness
4355
4378
 
@@ -4608,7 +4631,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
4608
4631
  | Function | Validates |
4609
4632
  | --- | --- |
4610
4633
  | `validateManifest(manifest)` | Identity, protocol-version interoperability, authentication methods, and idle-capability shapes. |
4611
- | `validateTask(task, { channel })` | Identity, channel agreement, phase, wrap allowance, capability shapes, custom controls, and browsers. |
4634
+ | `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`). |
4612
4635
  | `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. |
4613
4636
  | `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
4614
4637
  | `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
@@ -4725,8 +4748,11 @@ as each case was considered, so a test that needs a rule to have run asserts it
4725
4748
  inferring it from an empty `violations`, and a rule absent from it was never looked at, which is a
4726
4749
  gap and not a pass. With the audio open on a softphone,
4727
4750
  the drive mutes it for one second and reports the leg through `recordStep`, begun and then ended,
4728
- expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); and where the provider
4729
- restates the task's record afterwards, the leg is in it or the hole is named
4751
+ expecting each report `recorded` (`drive.recordStep.failed`, `.rejected`); then it mutes again and
4752
+ ends the call muted, as agents do, so the leg is open when the media ends, the provider closes it
4753
+ in the completing publication or the open entry is refused (`task.handlingHistory.muted.open`),
4754
+ and the drive's closing report after the media ended is expected `recorded` and to change nothing.
4755
+ Where the provider restates the task's record afterwards, each leg is in it or the hole is named
4730
4756
  (`drive.recordStep.history`). Given `rebuild`, a way to build the adapter again as a host reload
4731
4757
  does, the drive reloads the host as a reload happens: the first client is unsubscribed,
4732
4758
  disconnected and its session closed (`drive.reload.handover`), and only then is a second adapter
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.73",
3
+ "version": "0.1.75",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",