@xema/omni-protocol 0.1.74 → 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,
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 [];
@@ -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"))
@@ -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
@@ -4624,7 +4631,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
4624
4631
  | Function | Validates |
4625
4632
  | --- | --- |
4626
4633
  | `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. |
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`). |
4628
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. |
4629
4636
  | `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
4630
4637
  | `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.75",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",