@xema/omni-protocol 0.1.127 → 0.1.129

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
@@ -222,8 +222,8 @@ export interface Manifest<C extends Channel = Channel> {
222
222
  timeCheck?: true;
223
223
  /** Provider uses its own timestamps for final records; host timestamps are advisory. Omission makes no trust promise. */
224
224
  timestampAuthority?: "provider";
225
- /** The organisation's whole ladder, stated outright, `person` included. Omitted for the typical four, `DEFAULT_LEVELS`. */
226
- orgLevels?: LevelDeclaration[];
225
+ /** The organisation's whole ladder, stated outright, `person` included, each level labelled in the languages its agents read. */
226
+ orgLevels: LevelDeclaration[];
227
227
  /**
228
228
  * The outcomes this platform distinguishes when a dial ends, `answered` and at least one way of
229
229
  * not reaching the destination. Required of a provider that dials at all -- an idle dialpad, or
@@ -1026,35 +1026,15 @@ export interface TaskTakenOver {
1026
1026
  since: IsoTimestamp;
1027
1027
  }
1028
1028
  /**
1029
- * A level of the organisation's structure, by the id its manifest declares -- or one of the four
1030
- * a typical organisation has, `DEFAULT_LEVELS`, when the manifest declares no ladder. The protocol
1031
- * never describes the chain: which levels a person passes through is the structure's to know.
1029
+ * A level of the organisation's structure, by the id its manifest declares. The protocol never
1030
+ * describes the chain: which levels a person passes through is the structure's to know.
1032
1031
  */
1033
1032
  export type Level = string;
1034
- /** A level the structure has, with the label an agent computer shows for "who decided". */
1033
+ /** A level the structure has, with the label an agent computer shows for "who decided", per language: the agent application picks with `localizedText()`. */
1035
1034
  export interface LevelDeclaration {
1036
1035
  id: Level;
1037
- label: string;
1036
+ label: LocalizedText;
1038
1037
  }
1039
- /**
1040
- * The levels a typical organisation has: the ladder in force when a manifest declares no
1041
- * `orgLevels`. A manifest that declares any states its whole ladder outright.
1042
- */
1043
- export declare const DEFAULT_LEVELS: readonly [{
1044
- readonly id: "org";
1045
- readonly label: "Your organisation";
1046
- }, {
1047
- readonly id: "site";
1048
- readonly label: "Your site";
1049
- }, {
1050
- readonly id: "team";
1051
- readonly label: "Your team";
1052
- }, {
1053
- readonly id: "person";
1054
- readonly label: "You";
1055
- }];
1056
- /** The ladder in force for a manifest: exactly what it declares, or the defaults when it declares none. */
1057
- export declare function effectiveLevels(declared: readonly LevelDeclaration[] | undefined): LevelDeclaration[];
1058
1038
  /**
1059
1039
  * Something the queue could allow, locked above the person: the level that made it unchangeable,
1060
1040
  * and why if they said. `person` never locks their own value, and the queue is not a level --
package/dist/index.js CHANGED
@@ -63,20 +63,6 @@ export function isAllowedBrowserUrl(url) {
63
63
  }
64
64
  }
65
65
  export const ON_CALL_ROLES = ["party", "agent", "conferenced"];
66
- /**
67
- * The levels a typical organisation has: the ladder in force when a manifest declares no
68
- * `orgLevels`. A manifest that declares any states its whole ladder outright.
69
- */
70
- export const DEFAULT_LEVELS = [
71
- { id: "org", label: "Your organisation" },
72
- { id: "site", label: "Your site" },
73
- { id: "team", label: "Your team" },
74
- { id: "person", label: "You" },
75
- ];
76
- /** The ladder in force for a manifest: exactly what it declares, or the defaults when it declares none. */
77
- export function effectiveLevels(declared) {
78
- return [...(declared ?? DEFAULT_LEVELS)];
79
- }
80
66
  export const CAPABILITY_SOURCES = ["queue", "dialer", "nobody", "not-yet-read"];
81
67
  // ---------------------------------------------------------------------------
82
68
  // Task commands.
@@ -160,7 +146,8 @@ export function localizedText(text, languages) {
160
146
  return undefined;
161
147
  const byTag = new Map(Object.entries(text).map(([tag, value]) => [tag.toLowerCase(), value]));
162
148
  for (const language of languages) {
163
- for (let tag = language.toLowerCase(); tag !== ""; tag = tag.includes("-") ? tag.slice(0, tag.lastIndexOf("-")) : "") {
149
+ // RFC 4647 Lookup: drop subtags from the end, and a single-letter extension marker with the one before it.
150
+ for (let tag = language.toLowerCase(); tag !== ""; tag = shorterTag(tag)) {
164
151
  const found = byTag.get(tag);
165
152
  if (found !== undefined)
166
153
  return found;
@@ -168,6 +155,12 @@ export function localizedText(text, languages) {
168
155
  }
169
156
  return undefined;
170
157
  }
158
+ function shorterTag(tag) {
159
+ if (!tag.includes("-"))
160
+ return "";
161
+ const shorter = tag.slice(0, tag.lastIndexOf("-"));
162
+ return /-[a-z0-9]$/.test(shorter) ? shorter.slice(0, -2) : shorter;
163
+ }
171
164
  // ---------------------------------------------------------------------------
172
165
  // Utilities.
173
166
  // ---------------------------------------------------------------------------
package/dist/testing.d.ts CHANGED
@@ -80,9 +80,9 @@ export interface TestAdapterOptions {
80
80
  export declare function testAdapter<C extends Channel>(adapter: Adapter<C>, context: ConnectContext, options?: TestAdapterOptions): Promise<AdapterContractResult>;
81
81
  /**
82
82
  * Validates restored authentication followed by a refresh failure or expiry. Every state is
83
- * validated, and a `refreshing` state must carry over the login it refreshes.
83
+ * validated against the manifest's levels, and a `refreshing` state must carry over the login it refreshes.
84
84
  */
85
- export declare function assertAuthenticationRestoreAndExpiry(states: readonly AuthenticationState[]): void;
85
+ export declare function assertAuthenticationRestoreAndExpiry(states: readonly AuthenticationState[], manifest: Manifest): void;
86
86
  /**
87
87
  * Capabilities are current, not fixed. A provider that withdraws one republishes `authenticated`
88
88
  * with the new set, and the next snapshot agrees with it. `states` is what the authentication
package/dist/testing.js CHANGED
@@ -1,4 +1,4 @@
1
- import { browserSessionKey, effectiveLevels, sameCapabilities, } from "./index.js";
1
+ import { browserSessionKey, sameCapabilities, } from "./index.js";
2
2
  import { assertNoViolations, validateAuthenticationState, validateBreakTransition, validateEventEnvelope, validateHostGuarantees, validateHostReport, validateUserDetails, observeRules, ruleTested, validateHostMute, validateHostRecording, validateLoginStore, validateHistoryReport, validateManifest, validateResult, validateSnapshot, validateTask, validateLanguages, validateTimeZone, validatePhone, validateTaskCommand, isTimeZone, sameTimeZone, } from "./validation.js";
3
3
  export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
4
4
  /**
@@ -57,6 +57,10 @@ const CONTRACT_SUBJECTS = [
57
57
  // here assumes its shape; a subject is reached only by something the element rules would see.
58
58
  const isRecord = (value) => typeof value === "object" && value !== null;
59
59
  const some = (value) => Array.isArray(value) && value.length > 0;
60
+ // A manifest without a ladder has been reported on, and declares no level: every level is refused.
61
+ const declaredLevels = (manifest) => isRecord(manifest) && Array.isArray(manifest.orgLevels)
62
+ ? manifest.orgLevels.filter(isRecord).map(level => level.id).filter((id) => typeof id === "string")
63
+ : [];
60
64
  function observeTask(value, seen) {
61
65
  if (!isRecord(value))
62
66
  return;
@@ -249,7 +253,7 @@ export async function testAdapter(adapter, context, options = {}) {
249
253
  let disconnectWasClean = false;
250
254
  try {
251
255
  authenticationState = await authentication.state();
252
- const levels = effectiveLevels(adapter.manifest.orgLevels).map(level => level.id);
256
+ const levels = declaredLevels(adapter.manifest);
253
257
  violations.push(...validateAuthenticationState(authenticationState, "authentication", { levels }));
254
258
  if (authenticationState.status !== "authenticated") {
255
259
  throw new Error(`Adapter contract exercise requires authenticated test state, received ${authenticationState.status}`);
@@ -955,12 +959,12 @@ function connectingReports(context, violations) {
955
959
  };
956
960
  }
957
961
  /** Validates each state of a published sequence, and that every `refreshing` carries over the login before it. */
958
- function assertLoginSequence(states, summary) {
962
+ function assertLoginSequence(states, levels, summary) {
959
963
  const found = [];
960
964
  let login;
961
965
  states.forEach((state, index) => {
962
966
  const path = `states[${index}]`;
963
- const own = validateAuthenticationState(state, path);
967
+ const own = validateAuthenticationState(state, path, { levels });
964
968
  found.push(...own);
965
969
  if (own.length > 0)
966
970
  return;
@@ -973,10 +977,10 @@ function assertLoginSequence(states, summary) {
973
977
  }
974
978
  /**
975
979
  * Validates restored authentication followed by a refresh failure or expiry. Every state is
976
- * validated, and a `refreshing` state must carry over the login it refreshes.
980
+ * validated against the manifest's levels, and a `refreshing` state must carry over the login it refreshes.
977
981
  */
978
- export function assertAuthenticationRestoreAndExpiry(states) {
979
- assertLoginSequence(states, "Authentication scenario");
982
+ export function assertAuthenticationRestoreAndExpiry(states, manifest) {
983
+ assertLoginSequence(states, declaredLevels(manifest), "Authentication scenario");
980
984
  if (states.length < 2 || states[0]?.status !== "authenticated") {
981
985
  throw new Error("Authentication scenario must start with a restored authenticated state");
982
986
  }
@@ -1041,7 +1045,7 @@ function notYetReadTasks(tasks, path) {
1041
1045
  * leads, or requests to one that may no longer join, is the failure.
1042
1046
  */
1043
1047
  export function assertCapabilityWithdrawal(states, snapshot, manifest) {
1044
- assertLoginSequence(states, "Capability withdrawal");
1048
+ assertLoginSequence(states, declaredLevels(manifest), "Capability withdrawal");
1045
1049
  const stray = states.findIndex(state => state.status !== "authenticated" && state.status !== "refreshing");
1046
1050
  if (stray >= 0) {
1047
1051
  throw new Error(`Capability withdrawal passes only through usable states; states[${stray}] is ${states[stray]?.status}, which ends the login`);
@@ -1099,7 +1103,7 @@ export function assertTaskCapabilityWithdrawal(tasks, manifest, command) {
1099
1103
  }
1100
1104
  const context = {
1101
1105
  channel: manifest.channel,
1102
- levels: effectiveLevels(manifest.orgLevels).map(level => level.id),
1106
+ levels: declaredLevels(manifest),
1103
1107
  dialOutcomesDeclared: manifest.dialOutcomes !== undefined,
1104
1108
  };
1105
1109
  tasks.forEach((task, index) => {
@@ -1690,7 +1694,7 @@ async function testOneCall(call) {
1690
1694
  // The reload is a restore before it is anything else: the second session stands authenticated
1691
1695
  // as the same person, from the secrets alone, or nothing it reads afterwards is this login's.
1692
1696
  const restored = await restoredSession.state();
1693
- found.push(...validateAuthenticationState(restored, "test.reload.login"));
1697
+ found.push(...validateAuthenticationState(restored, "test.reload.login", { levels: declaredLevels(call.manifest) }));
1694
1698
  ruleTested("test.reload.login");
1695
1699
  const same = isRecord(restored) && restored.status === "authenticated" && isRecord(restored.identity) && restored.identity.id === call.reader().self;
1696
1700
  if (!same) {
@@ -1836,7 +1840,7 @@ async function testOneCall(call) {
1836
1840
  ruleTested("test.timeout");
1837
1841
  const send = async (command, dialId) => {
1838
1842
  const own = validateTaskCommand(command, latestTask(), `test.command.${String(command.type)}`, {
1839
- levels: effectiveLevels(call.manifest.orgLevels).map(level => level.id),
1843
+ levels: declaredLevels(call.manifest),
1840
1844
  dialOutcomesDeclared: call.manifest.dialOutcomes !== undefined,
1841
1845
  calendarDeclared: call.manifest.idleCapabilities?.calendar === true,
1842
1846
  autoAcceptTasks: call.context.autoAcceptTasks,
@@ -15,8 +15,8 @@ export declare function observeRules(listener: (rule: string) => void): () => vo
15
15
  /** Says a rule was evaluated, for code that decides outside a collector: the stream, the test. */
16
16
  export declare function ruleTested(...rules: readonly string[]): void;
17
17
  /** Every field is optional, so this checks what is present rather than what is missing. */
18
- export declare function validateContact(contact: unknown, path?: string): ProtocolViolation[];
19
- export declare function validateScheduledActivity(activity: unknown, path?: string): ProtocolViolation[];
18
+ export declare function validateContact(contact: unknown, levels: readonly string[], path?: string): ProtocolViolation[];
19
+ export declare function validateScheduledActivity(activity: unknown, levels: readonly string[], path?: string): ProtocolViolation[];
20
20
  /**
21
21
  * The host's choice of phone for a login, held to the manifest: one the platform listed, required
22
22
  * on voice, absent elsewhere.
@@ -37,8 +37,8 @@ export declare function validateLabels(value: unknown, path?: string): ProtocolV
37
37
  export interface TaskValidationContext {
38
38
  /** The provider's channel, from its manifest. A task must agree with it. */
39
39
  channel: string;
40
- /** The level ids in force, from the manifest. The defaults when absent. */
41
- levels?: readonly string[];
40
+ /** The level ids the manifest declares, `Manifest.orgLevels`: a level the task names must be one of them. */
41
+ levels: readonly string[];
42
42
  /** `ConnectContext.autoAcceptTasks` as sent: whether a pending task states its `acceptance`. Unknown to a caller without the context, and then unchecked. */
43
43
  autoAcceptTasks?: boolean;
44
44
  /** Whether the manifest declares `dialOutcomes`. A task that may dial needs it to; absent, the question is not asked. */
@@ -99,7 +99,7 @@ export interface ReaderContext {
99
99
  * snapshot carries a team member list, nobody else's does, and `requests` need `team.leadAssistControl`.
100
100
  */
101
101
  capabilities?: UserCapabilities;
102
- /** The level ids in force. Filled from the manifest by `validateSnapshot` and `validateEventEnvelope`; the defaults otherwise. */
102
+ /** The level ids the manifest declares. `validateSnapshot` and `validateEventEnvelope` take them from the manifest unless given. */
103
103
  levels?: readonly string[];
104
104
  /** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
105
105
  loginId?: string;
@@ -125,15 +125,19 @@ export interface ReaderContext {
125
125
  /** `ConnectContext.autoAcceptTasks` as sent: whether a pending task states its `acceptance`. Unknown to a caller without the context, and then unchecked. */
126
126
  autoAcceptTasks?: boolean;
127
127
  }
128
- export declare function validateTeamMembers(teamMembers: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
128
+ /** What a team member list is validated against: the reader, and the levels the manifest declares. */
129
+ export type TeamValidationContext = ReaderContext & {
130
+ levels: readonly string[];
131
+ };
132
+ export declare function validateTeamMembers(teamMembers: unknown, path: string, context: TeamValidationContext): ProtocolViolation[];
129
133
  /** One member as the lead sees them, whole: on the list, and on `team-member-updated` alone. */
130
- export declare function validateTeamMember(member: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
134
+ export declare function validateTeamMember(member: unknown, path: string, context: TeamValidationContext): ProtocolViolation[];
131
135
  export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
132
136
  export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
133
137
  /** The agent's standing ask for the next call: since when. */
134
138
  export declare function validateNextCall(value: unknown, path?: string): ProtocolViolation[];
135
139
  /** A call waiting in the agent's own queue: the caller as shown to this audience, the queue, the waits, and whether the agent may let it go. */
136
- export declare function validateLinedUp(value: unknown, path?: string, levels?: readonly string[]): ProtocolViolation[];
140
+ export declare function validateLinedUp(value: unknown, levels: readonly string[], path?: string): ProtocolViolation[];
137
141
  /** The phone as the platform sees it: the agent's device for this provider, whether it can take a call, its own mute where observed. */
138
142
  export declare function validatePhoneState(value: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
139
143
  /**
@@ -159,7 +163,7 @@ export declare function validateHostGuarantees(guarantees: unknown, path?: strin
159
163
  export declare function validateHistoryReport(report: unknown, path?: string, manifest?: unknown, task?: unknown): ProtocolViolation[];
160
164
  export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
161
165
  /** Validate the exact interaction/assignment target before provider command dispatch. */
162
- export declare function validateTaskCommandRequest(request: unknown, task: unknown, path?: string, taskContext?: Omit<TaskValidationContext, "channel">): ProtocolViolation[];
166
+ export declare function validateTaskCommandRequest(request: unknown, task: unknown, path: string, taskContext: Omit<TaskValidationContext, "channel">): ProtocolViolation[];
163
167
  /**
164
168
  * What a command needs to be issuable, checked against the task it names: the capability the
165
169
  * guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
@@ -167,7 +171,8 @@ export declare function validateTaskCommandRequest(request: unknown, task: unkno
167
171
  * own shape is checked. A host validates before sending, and an adapter before acting: a command
168
172
  * for a control the task never offered is the host's error, and this names it.
169
173
  */
170
- export declare function validateTaskCommand(command: unknown, task?: unknown, path?: string, taskContext?: Omit<TaskValidationContext, "channel">): ProtocolViolation[];
174
+ export declare function validateTaskCommand(command: unknown): ProtocolViolation[];
175
+ export declare function validateTaskCommand(command: unknown, task: unknown, path: string, taskContext: Omit<TaskValidationContext, "channel">): ProtocolViolation[];
171
176
  /** The connection methods whose results `validateResult` knows. */
172
177
  export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "requestNextCall" | "cancelNextCall" | "releaseLinedUp" | "executeTeam" | "openAudio" | "setPreference" | "recordStep";
173
178
  /**
@@ -191,8 +196,8 @@ export declare function validateTimeZone(value: unknown, path?: string): Protoco
191
196
  export declare function validateUserDetails(users: unknown, asked: readonly string[], path?: string): ProtocolViolation[];
192
197
  /** What a login is validated against beyond its own shape. */
193
198
  export interface LoginValidationContext {
194
- /** The level ids in force, from the manifest. The defaults when absent. */
195
- levels?: readonly string[];
199
+ /** The level ids the manifest declares, `Manifest.orgLevels`: a level the login names must be one of them. */
200
+ levels: readonly string[];
196
201
  }
197
202
  export declare function validateAuthenticationFailure(failure: unknown, path?: string): ProtocolViolation[];
198
203
  /**
@@ -206,8 +211,8 @@ export declare function validateRedirectUri(value: unknown, path?: string): Prot
206
211
  * rejection from `complete`. A rejection carries a failure the host can show, and a login is held
207
212
  * to the same rules as any `authenticated` state.
208
213
  */
209
- export declare function validateAuthenticationResult(result: unknown, method: "start" | "complete", path?: string, context?: LoginValidationContext): ProtocolViolation[];
210
- export declare function validateAuthenticationState(state: unknown, path?: string, context?: LoginValidationContext): ProtocolViolation[];
214
+ export declare function validateAuthenticationResult(result: unknown, method: "start" | "complete", path: string, context: LoginValidationContext): ProtocolViolation[];
215
+ export declare function validateAuthenticationState(state: unknown, path: string, context: LoginValidationContext): ProtocolViolation[];
211
216
  /**
212
217
  * One recorder's recordings for one task, as published: each with its id, the side it follows,
213
218
  * its status, and a reason where it is paused or unknown; ids unique, and at most one recording
@@ -231,7 +236,7 @@ export declare function validateRecordingRequest(request: unknown, task: unknown
231
236
  hostReport?: unknown;
232
237
  softphone?: boolean;
233
238
  /** The same provider/task context used to validate the published task. */
234
- taskContext?: Omit<TaskValidationContext, "channel">;
239
+ taskContext: Omit<TaskValidationContext, "channel">;
235
240
  }, path?: string): ProtocolViolation[];
236
241
  /**
237
242
  * A recording command's answer against the recordings published before and after it: applied
@@ -9,7 +9,7 @@
9
9
  // Each list is pinned to its type both ways -- a member the type lacks, or a member the list
10
10
  // lacks, fails to compile -- so what the validators accept cannot drift from what the
11
11
  // declarations say.
12
- import { RECORDING_ACTIONS, SHIFT_EVENT_KINDS, RECORDING_SIDES, RECORDING_STATUSES, RECORDING_PAUSED_REASONS, RECORDING_UNKNOWN_REASONS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HISTORY_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, negotiateProtocolVersion, DEFAULT_LEVELS, effectiveLevels, } from "./index.js";
12
+ import { RECORDING_ACTIONS, SHIFT_EVENT_KINDS, RECORDING_SIDES, RECORDING_STATUSES, RECORDING_PAUSED_REASONS, RECORDING_UNKNOWN_REASONS, ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, HISTORY_STEPS_THAT_DIAL, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, TASK_COMMAND_NAMES, negotiateProtocolVersion, } from "./index.js";
13
13
  export class ProtocolConformanceError extends Error {
14
14
  violations;
15
15
  constructor(violations, summary = "Adapter violates the Omni protocol") {
@@ -217,9 +217,9 @@ function validateAttributes(value, path, into) {
217
217
  });
218
218
  }
219
219
  /** Every field is optional, so this checks what is present rather than what is missing. */
220
- export function validateContact(contact, path = "contact") {
220
+ export function validateContact(contact, levels, path = "contact") {
221
221
  const into = new Collector();
222
- validateContactInto(contact, path, into);
222
+ validateContactInto(contact, path, into, levels);
223
223
  return into.violations;
224
224
  }
225
225
  function validateContactInto(contact, path, into, levels) {
@@ -238,9 +238,9 @@ function validateContactInto(contact, path, into, levels) {
238
238
  }
239
239
  validateAttributes(contact.attributes, `${path}.attributes`, into);
240
240
  }
241
- export function validateScheduledActivity(activity, path = "scheduledActivity") {
241
+ export function validateScheduledActivity(activity, levels, path = "scheduledActivity") {
242
242
  const into = new Collector();
243
- validateScheduledActivityInto(activity, path, into);
243
+ validateScheduledActivityInto(activity, path, into, levels);
244
244
  return into.violations;
245
245
  }
246
246
  function validateScheduledActivityInto(activity, path, into, levels) {
@@ -528,9 +528,12 @@ export function validateManifest(manifest, path = "manifest") {
528
528
  into.require(!Object.hasOwn(manifest, former), "manifest.settleMs.renamed", `${path}.${former}`, "use settleMs; the former field is not accepted");
529
529
  }
530
530
  into.require(typeof manifest.settleMs === "number" && Number.isInteger(manifest.settleMs) && manifest.settleMs > 0, "manifest.settleMs", `${path}.settleMs`, "settleMs is how long after applied the wire shows what the provider did -- the task-ended after a complete, the calendar-updated after a schedule -- a positive whole number of milliseconds, stated by every provider");
531
- if (manifest.orgLevels !== undefined) {
531
+ if (manifest.orgLevels === undefined) {
532
+ into.add("manifest.orgLevels.required", `${path}.orgLevels`, "orgLevels is required: the organisation's whole ladder, person included, each level labelled in the languages its agents read");
533
+ }
534
+ else {
532
535
  if (!Array.isArray(manifest.orgLevels)) {
533
- into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array when present");
536
+ into.add("manifest.orgLevels.shape", `${path}.orgLevels`, "orgLevels must be an array");
534
537
  }
535
538
  else {
536
539
  const ids = new Set();
@@ -551,7 +554,9 @@ export function validateManifest(manifest, path = "manifest") {
551
554
  }
552
555
  else
553
556
  wellFormed = false;
554
- if (!into.filled(level.label, "manifest.orgLevel.label", `${at}.label`, "a level needs the label an agent computer shows for it"))
557
+ const before = into.violations.length;
558
+ validateLocalizedText(level.label, "manifest.orgLevel.label", `${at}.label`, into);
559
+ if (into.violations.length !== before)
555
560
  wellFormed = false;
556
561
  });
557
562
  if (wellFormed && !ids.has("person")) {
@@ -561,8 +566,17 @@ export function validateManifest(manifest, path = "manifest") {
561
566
  }
562
567
  return into.violations;
563
568
  }
564
- /** Language tags as BCP 47 writes them: a language, then subtags of up to eight letters or digits. */
565
- const LANGUAGE_TAG = /^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{1,8})*$/;
569
+ /** A well-formed BCP 47 tag as ECMA-402 reads one, a Unicode locale identifier: judged by its spelling, never by what the machine has installed. */
570
+ function isLanguageTag(value) {
571
+ if (typeof value !== "string" || value === "")
572
+ return false;
573
+ try {
574
+ return Intl.getCanonicalLocales(value).length === 1;
575
+ }
576
+ catch {
577
+ return false;
578
+ }
579
+ }
566
580
  /** The agent's languages, as a host states them on both contexts: BCP 47 tags, most preferred first, at least one. */
567
581
  export function validateLanguages(value, path = "context.languages") {
568
582
  const into = new Collector();
@@ -572,7 +586,7 @@ export function validateLanguages(value, path = "context.languages") {
572
586
  }
573
587
  const seen = new Set();
574
588
  value.forEach((tag, index) => {
575
- if (typeof tag !== "string" || !LANGUAGE_TAG.test(tag)) {
589
+ if (!isLanguageTag(tag)) {
576
590
  into.add("context.languages.tag", `${path}[${index}]`, `${describeValue(tag)} is not a BCP 47 language tag`);
577
591
  return;
578
592
  }
@@ -633,7 +647,7 @@ function validateLocalizedText(value, rule, path, into) {
633
647
  }
634
648
  const seen = new Set();
635
649
  for (const [tag, text] of Object.entries(value)) {
636
- if (!into.require(LANGUAGE_TAG.test(tag), `${rule}.language`, `${path}.${tag}`, `${describeValue(tag)} is not a language tag such as en, hi or en-IN`))
650
+ if (!into.require(isLanguageTag(tag), `${rule}.language`, `${path}.${tag}`, `${describeValue(tag)} is not a language tag such as en, hi or en-IN`))
637
651
  continue;
638
652
  into.require(!seen.has(tag.toLowerCase()), `${rule}.language.unique`, `${path}.${tag}`, "language tags are case-insensitive, and each language is stated once");
639
653
  seen.add(tag.toLowerCase());
@@ -757,15 +771,12 @@ function validateCustomCapabilities(value, path, into) {
757
771
  }
758
772
  });
759
773
  }
760
- const DEFAULT_LEVEL_IDS = DEFAULT_LEVELS.map(level => level.id);
761
774
  const POLICY_SETTINGS = membersOf({ on: true, off: true, person: true });
762
775
  const POLICY_KEYS = new Set([
763
776
  ...TASK_CAPABILITIES.voice, ...TASK_CAPABILITIES.chat, ...TASK_CAPABILITIES.email, "dial",
764
777
  ].filter(name => name !== "browsers" && name !== "outcomes" && name !== "custom"));
765
778
  const PERSON_SETTABLE = /^(hold|skill:.+)$/;
766
779
  const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
767
- /** The level ids in force: the manifest's, or the defaults when the caller holds no manifest. */
768
- const levelIds = (levels) => levels ?? DEFAULT_LEVEL_IDS;
769
780
  /**
770
781
  * What the queue locks is locked on the whole task. Given the locked values, every other field an
771
782
  * agent reads -- the title, the reference, an attribute, a custom control's label, a browser URL the
@@ -818,7 +829,7 @@ function validateLockedByInto(value, rule, path, levels, into) {
818
829
  if (!into.filled(value, rule, path, "lockedBy names the level that locked it"))
819
830
  return;
820
831
  into.require(value !== "person", `${rule}.person`, path, "a person never locks their own value");
821
- into.require(levelIds(levels).includes(value), `${rule}.unknown`, path, `${describeValue(value)} is not a level this manifest declares: in force are ${levelIds(levels).join(", ")}`);
832
+ into.require(levels.includes(value), `${rule}.unknown`, path, `${describeValue(value)} is not a level this manifest declares: ${inForce(levels)}`);
822
833
  }
823
834
  /** `{ lockedBy, reason? }` standing in for a value: who locked it, and a reason if given. */
824
835
  function validateLockedInto(value, rule, path, levels, into) {
@@ -829,7 +840,7 @@ function validateLockedInto(value, rule, path, levels, into) {
829
840
  /** What every resolved value carries: who set it, and who locked it if anyone. */
830
841
  function validateResolvedInto(value, rule, path, levels, into) {
831
842
  if (into.filled(value.setBy, `${rule}.setBy`, `${path}.setBy`, "setBy names who stated the value: a level, or provider")) {
832
- into.require(value.setBy === "provider" || levelIds(levels).includes(value.setBy), `${rule}.setBy.unknown`, `${path}.setBy`, `${describeValue(value.setBy)} is neither provider nor a level this manifest declares`);
843
+ into.require(value.setBy === "provider" || levels.includes(value.setBy), `${rule}.setBy.unknown`, `${path}.setBy`, `${describeValue(value.setBy)} is neither provider nor a level this manifest declares: ${inForce(levels)}`);
833
844
  }
834
845
  if (value.lockedBy !== undefined)
835
846
  validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, levels, into);
@@ -838,14 +849,22 @@ function validateResolvedInto(value, rule, path, levels, into) {
838
849
  into.require(value.lockedBy !== undefined, `${rule}.reason.unexpected`, `${path}.reason`, "a reason goes with lockedBy: it says why it was locked");
839
850
  }
840
851
  }
841
- /** The level ids a manifest puts in force, for validators that receive one. */
852
+ /** A command context's `levels`, the manifest's level ids the login is validated against. */
853
+ function contextLevels(context, rule, path, into) {
854
+ const levels = context.levels;
855
+ if (Array.isArray(levels) && levels.every(level => typeof level === "string"))
856
+ return levels;
857
+ into.add(rule, `${path}.levels`, "the context carries levels, the ids of the manifest's orgLevels, to validate the login against");
858
+ return [];
859
+ }
860
+ const inForce = (levels) => levels.length === 0 ? "it declares no ladder" : `in force are ${levels.join(", ")}`;
861
+ /** The level ids a manifest declares, for validators that receive one. A manifest without a ladder declares none, and every level is refused. */
842
862
  function manifestLevels(manifest) {
843
- if (!isPlainObject(manifest))
844
- return undefined;
845
- const declared = Array.isArray(manifest.orgLevels)
846
- ? manifest.orgLevels.filter((level) => isPlainObject(level) && typeof level.id === "string")
847
- : undefined;
848
- return effectiveLevels(declared).map(level => level.id);
863
+ if (!isPlainObject(manifest) || !Array.isArray(manifest.orgLevels))
864
+ return [];
865
+ return manifest.orgLevels
866
+ .filter((level) => isPlainObject(level) && typeof level.id === "string")
867
+ .map(level => level.id);
849
868
  }
850
869
  const CUSTOM_RENDERS = membersOf({ inline: true, page: true });
851
870
  const CREDENTIAL_FIELD_TYPES = membersOf({ text: true, password: true });
@@ -1435,7 +1454,7 @@ export function validateTeamCommand(request, context, path = "teamCommand") {
1435
1454
  return into.violations;
1436
1455
  }
1437
1456
  const auth = isPlainObject(context.authentication) ? context.authentication : {};
1438
- into.violations.push(...validateAuthenticationState(auth, `${path}.authentication`));
1457
+ into.violations.push(...validateAuthenticationState(auth, `${path}.authentication`, { levels: contextLevels(context, "team.command.levels", path, into) }));
1439
1458
  const caps = isPlainObject(auth.capabilities) ? auth.capabilities : {};
1440
1459
  into.require((auth.status === "authenticated" || auth.status === "refreshing") && context.transport === "active", "team.command.connection", path, "lead commands require a live login and active transport");
1441
1460
  into.require(caps.lead === true, "team.command.capability", path, "the login must declare lead");
@@ -1529,7 +1548,7 @@ export function validateBreakCommand(method, request, state, context, path = "br
1529
1548
  into.add("break.command.context", path, "current authentication and transport are required");
1530
1549
  return into.violations;
1531
1550
  }
1532
- into.violations.push(...validateAuthenticationState(context.authentication, `${path}.authentication`));
1551
+ into.violations.push(...validateAuthenticationState(context.authentication, `${path}.authentication`, { levels: contextLevels(context, "break.command.levels", path, into) }));
1533
1552
  const auth = isPlainObject(context.authentication) ? context.authentication : {};
1534
1553
  into.require(auth.status === "authenticated" || auth.status === "refreshing", "break.command.authentication", path, "break commands require a live authenticated login");
1535
1554
  into.require(isPlainObject(auth.capabilities) && auth.capabilities.breaks === true, "break.command.capability", path, "the login must declare breaks");
@@ -1677,7 +1696,7 @@ function validateBreakState(value, path, into) {
1677
1696
  into.require(typeof value.activeReasonId === "string" && value.activeReasonId.length > 0, "break.activeReasonId.required", `${path}.activeReasonId`, `a break ${describeValue(value.status)} on a provider that publishes reasons names the one it is on`);
1678
1697
  }
1679
1698
  }
1680
- export function validateTeamMembers(teamMembers, path = "team", context = {}) {
1699
+ export function validateTeamMembers(teamMembers, path, context) {
1681
1700
  const into = new Collector();
1682
1701
  validateTeamMembersInto(teamMembers, path, context, into);
1683
1702
  return into.violations;
@@ -1724,7 +1743,7 @@ function validateTeamMembersInto(teamMembers, path, context, into) {
1724
1743
  }
1725
1744
  }
1726
1745
  /** One member as the lead sees them, whole: on the list, and on `team-member-updated` alone. */
1727
- export function validateTeamMember(member, path = "member", context = {}) {
1746
+ export function validateTeamMember(member, path, context) {
1728
1747
  const into = new Collector();
1729
1748
  validateTeamMemberInto(member, path, context, into);
1730
1749
  return into.violations;
@@ -2299,7 +2318,7 @@ function validateNextCallInto(value, path, into) {
2299
2318
  into.require(key === "since", "nextCall.field", `${path}.${key}`, "unsupported field on the ask");
2300
2319
  }
2301
2320
  /** A call waiting in the agent's own queue: the caller as shown to this audience, the queue, the waits, and whether the agent may let it go. */
2302
- export function validateLinedUp(value, path = "linedUp", levels) {
2321
+ export function validateLinedUp(value, levels, path = "linedUp") {
2303
2322
  const into = new Collector();
2304
2323
  validateLinedUpInto(value, path, into, levels);
2305
2324
  return into.violations;
@@ -2651,7 +2670,7 @@ export function validateHostReport(report, path = "host") {
2651
2670
  const LEAD_ASSIST_ACTIONS = ["request", "cancel"];
2652
2671
  const CONFERENCE_ACTIONS = ["add", "remove"];
2653
2672
  /** Validate the exact interaction/assignment target before provider command dispatch. */
2654
- export function validateTaskCommandRequest(request, task, path = "request", taskContext) {
2673
+ export function validateTaskCommandRequest(request, task, path, taskContext) {
2655
2674
  const into = new Collector();
2656
2675
  if (!isPlainObject(request) || !isPlainObject(task)) {
2657
2676
  into.add("command.request.shape", path, "a request and the current published task are required");
@@ -2665,13 +2684,6 @@ export function validateTaskCommandRequest(request, task, path = "request", task
2665
2684
  into.violations.push(...validateTaskCommand(request.command, task, `${path}.command`, taskContext));
2666
2685
  return into.violations;
2667
2686
  }
2668
- /**
2669
- * What a command needs to be issuable, checked against the task it names: the capability the
2670
- * guide's table gates it on, the phase it belongs to, and the state that has to stand -- a
2671
- * lead requested, somebody else on the call. Without a task only the command's
2672
- * own shape is checked. A host validates before sending, and an adapter before acting: a command
2673
- * for a control the task never offered is the host's error, and this names it.
2674
- */
2675
2687
  export function validateTaskCommand(command, task, path = "command", taskContext) {
2676
2688
  const into = new Collector();
2677
2689
  if (!isPlainObject(command)) {
@@ -2762,6 +2774,8 @@ export function validateTaskCommand(command, task, path = "command", taskContext
2762
2774
  }
2763
2775
  if (task === undefined)
2764
2776
  return into.violations;
2777
+ if (taskContext === undefined)
2778
+ throw new TypeError("validateTaskCommand: a task is validated against the manifest's levels; pass taskContext with it");
2765
2779
  // The task the command is held to has to be one the wire published. A host's own mapping of it,
2766
2780
  // handed in for want of the original, is not, and checking a command against it would be
2767
2781
  // checking against a task nobody has -- so the task is validated first, and a command is held
@@ -3182,7 +3196,7 @@ function validateAuthenticationChallenge(value, path, into) {
3182
3196
  * rejection from `complete`. A rejection carries a failure the host can show, and a login is held
3183
3197
  * to the same rules as any `authenticated` state.
3184
3198
  */
3185
- export function validateAuthenticationResult(result, method, path = "result", context = {}) {
3199
+ export function validateAuthenticationResult(result, method, path, context) {
3186
3200
  const into = new Collector();
3187
3201
  if (!isPlainObject(result)) {
3188
3202
  into.add("authentication.result.shape", path, `${method} must answer an object`);
@@ -3206,7 +3220,7 @@ export function validateAuthenticationResult(result, method, path = "result", co
3206
3220
  }
3207
3221
  return into.violations;
3208
3222
  }
3209
- export function validateAuthenticationState(state, path = "authentication", context = {}) {
3223
+ export function validateAuthenticationState(state, path, context) {
3210
3224
  const into = new Collector();
3211
3225
  if (!isPlainObject(state)) {
3212
3226
  into.add("authentication.shape", path, "an authentication state must be an object");
package/guide/agent.md CHANGED
@@ -1081,7 +1081,7 @@ it no longer owns. Omni shows these controls only in the two interaction phases,
1081
1081
  The commands with a phase of their own -- `answer`, `accept` and `decline` in `pending`,
1082
1082
  `dial` in `preview`, `connect-back` in `completing`, `complete` in any -- are not among them.
1083
1083
 
1084
- `validateTaskCommand(command, task)` holds a command to this table at runtime, both ways: the
1084
+ `validateTaskCommand(command, task, path, { levels })` holds a command to this table at runtime, both ways: the
1085
1085
  capability it needs, the phase it belongs to, and the state that has to stand. The task it wants
1086
1086
  is the one the provider published, not an agent application's own mapping of it: an agent application that keeps only its
1087
1087
  mapped shape has nothing honest to pass, and then checks shape alone, which is still worth doing
package/guide/queue.md CHANGED
@@ -91,7 +91,8 @@ carries `nextCall` and `linedUp` as the member's own snapshot does, republished
91
91
  ### Runtime break prerequisite checks
92
92
 
93
93
  Use `validateBreakCommand(method, request, state, context)` from the validation entry point
94
- before any agent break dispatch. The context supplies current authentication and transport;
94
+ before any agent break dispatch. The context supplies current authentication and transport, and
95
+ the manifest's `levels` the login is validated against;
95
96
  all four methods require the live login's break capability and active transport. Pass the
96
97
  request object only to the request method, and undefined to the other three.
97
98
 
@@ -102,7 +103,7 @@ request object only to the request method, and undefined to the other three.
102
103
  | `cancelBreak` | `awaiting-approval` or `granted`. A concurrent commit winning still answers `omni.break-already-committed` and requires recovery. |
103
104
  | `endBreak` | `on-break` or `starting-after-task` during reconciliation; the agent may explicitly end a requested or forced break. |
104
105
 
105
- Use `validateTeamCommand(request, context)` before every lead act: the team feature switch, break
106
+ Use `validateTeamCommand(request, context)` before every lead act, its context carrying the manifest's `levels` as the break check's does: the team feature switch, break
106
107
  decisions, forcing and ending a forced break, policy, and every act on a member's call. It requires
107
108
  the live `lead` flag and active transport, a current team member list for any command naming a
108
109
  member, and the target's complete break state for forcing/ending a break. Approve/deny requires an
package/guide.md CHANGED
@@ -233,7 +233,7 @@ type Manifest<C extends Channel = Channel> = {
233
233
  idleCapabilities?: IdleCapabilities<C>;
234
234
  timeCheck?: true;
235
235
  timestampAuthority?: "provider";
236
- orgLevels?: LevelDeclaration[];
236
+ orgLevels: LevelDeclaration[];
237
237
  dialOutcomes?: C extends "voice" ? DialOutcome[] : never;
238
238
  phones?: C extends "voice" ? Phone[] : never;
239
239
  phoneStatus?: C extends "voice" ? true : never;
@@ -765,7 +765,7 @@ type Level = string;
765
765
 
766
766
  type LevelDeclaration = {
767
767
  id: Level;
768
- label: string;
768
+ label: LocalizedText;
769
769
  };
770
770
 
771
771
  type Locked = {
@@ -913,13 +913,13 @@ are the provider's ladder: each level states only what it sets, an enforcing pol
913
913
  the person settles the value for everyone below it, and where nothing enforces the most specific
914
914
  level that says anything wins. The protocol names a level by the id the manifest declares for it and
915
915
  never describes the chain between them: which levels a person passes through is the structure's to
916
- know. A typical organisation has four, and they are the defaults — `DEFAULT_LEVELS`: `org`, `site`,
917
- `team`, `person`, each with the label an agent computer shows. A structure that differs states its whole
918
- ladder in `Manifest.orgLevels`, `person` included: what the list carries is in force, and what it
916
+ know. Every provider states its whole ladder in `Manifest.orgLevels` — a typical organisation's is
917
+ `org`, `site`, `team`, `person` — each level with the label an agent computer shows, in the languages
918
+ its agents read. There is no ladder by default: a default would carry its labels in one language
919
+ only. The ladder includes `person`, and it is the whole ladder: what the list carries is in force, and what it
919
920
  leaves out does not exist — a structure with no site level declares `org`, `team`, `person`, and
920
921
  `site` is refused on its wire. A declared level is one the provider's own store actually resolves
921
- at: a label with no policy behind it decides nothing. A manifest that declares none has exactly
922
- the four. `lockedBy` is any level in force except `person`, who never locks their own value;
922
+ at: a label with no policy behind it decides nothing. `lockedBy` is any level in force except `person`, who never locks their own value;
923
923
  `setBy` is any level in force, or `provider`, the protocol's word for "no level has said
924
924
  anything and the provider's own configuration supplied the value". An agent application renders "who decided" from the
925
925
  declared labels and needs no others, and validates every republished `authenticated` state
@@ -1319,13 +1319,6 @@ const ALLOWED_BROWSER_URL_SCHEMES = ["http:", "https:"] as const;
1319
1319
  const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"] as const;
1320
1320
  type IdleCapability = (typeof IDLE_CAPABILITIES)[number];
1321
1321
 
1322
- const DEFAULT_LEVELS = [
1323
- { id: "org", label: "Your organisation" },
1324
- { id: "site", label: "Your site" },
1325
- { id: "team", label: "Your team" },
1326
- { id: "person", label: "You" },
1327
- ] as const satisfies readonly LevelDeclaration[];
1328
-
1329
1322
  const IDLE_CAPABILITY_UI = {
1330
1323
  dial: "Dialpad",
1331
1324
  personalBrowser: "Browser",
@@ -1762,6 +1755,12 @@ export default defineAdapter({
1762
1755
  supportedProtocolVersions: [OMNI_PROTOCOL_VERSION],
1763
1756
  authenticationMethods: ["browser-sso"],
1764
1757
  settleMs: 5000,
1758
+ orgLevels: [
1759
+ { id: "org", label: { en: "Your organisation", hi: "आपका संगठन" } },
1760
+ { id: "site", label: { en: "Your site", hi: "आपकी साइट" } },
1761
+ { id: "team", label: { en: "Your team", hi: "आपकी टीम" } },
1762
+ { id: "person", label: { en: "You", hi: "आप" } },
1763
+ ],
1765
1764
  idleCapabilities: {
1766
1765
  dial: { destinations: "any-number" },
1767
1766
  },
@@ -1791,7 +1790,7 @@ compile time.
1791
1790
  | `supportedProtocolVersions` | Required non-empty list of versions this adapter can speak. Must share at least one with Omni. |
1792
1791
  | `authenticationMethods` | Required non-empty list of supported login methods. |
1793
1792
  | `idleCapabilities` | Declares actions Omni may offer while the agent has no active task, such as voice dialing. Task controls do not belong here. |
1794
- | `orgLevels` | The organisation's whole ladder as the provider calls it, each level with the label an agent computer shows for "who decided". Stated outright, `person` included: what it leaves out does not exist. Omitted for the typical four, `DEFAULT_LEVELS`. See **Who decides what an agent may do**. |
1793
+ | `orgLevels` | The organisation's whole ladder as the provider calls it, each level with the label an agent computer shows for "who decided", one text per language as `{ en: "Your region", hi: "आपका क्षेत्र" }`, from which the agent application picks with `localizedText()` (`manifest.orgLevel.label`, `.language`, `.text`). Required of every provider (`manifest.orgLevels.required`), stated outright, `person` included: what it leaves out does not exist. There is no default ladder. See **Who decides what an agent may do**. |
1795
1794
  | `phones` | Voice only, and required there: the phones this platform can put an agent on, `softphone` (the call's audio lands in the agent application) and/or `hardphone` (a handset the platform rings; the agent application shows the call and opens nothing). The agent application picks one per login. See **How the agent hears the call** in `guide/phone.md`. |
1796
1795
  | `phoneStatus` | Voice only, declared by presence as `true`: the platform sees the phone itself -- its registration, its do-not-disturb, its hook, its calls -- and publishes it as `Snapshot.phone` and `phone-updated`. A platform that cannot see the phone omits it, and then publishes no phone (`manifest.phoneStatus`). See **The phone's own view** in `guide/phone.md`. |
1797
1796
  | `dialOutcomes` | Voice only. How a dial can end on this platform, as it distinguishes them: `answered` and at least one way of not reaching the destination. Required of a provider that dials at all — an idle dialpad, or tasks that conference or call back — and a `dial-outcome` carries only a declared member. See **Every dial has an outcome** in `guide/voice.md`. |
@@ -3080,11 +3079,11 @@ same exported checks are used by Omni and adapter tests so their interpretations
3080
3079
  | Function | Validates |
3081
3080
  | --- | --- |
3082
3081
  | `validateManifest(manifest)` | Identity, protocol-version interoperability, authentication methods, and idle-capability shapes. |
3083
- | `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`). |
3082
+ | `validateTask(task, { channel, levels, 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`). |
3084
3083
  | `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team member list, 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. |
3085
3084
  | `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
3086
- | `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
3087
- | `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
3085
+ | `validateContact(contact, levels)` | Contact field shapes and attribute keys, and a locked number or email locked by a level `levels` names. Every field is optional, so this checks what is present rather than what is missing. |
3086
+ | `validateScheduledActivity(activity, levels)` | Required activity fields and start/end ordering, and its party as `validateContact` holds one. |
3088
3087
  | `validateHostGuarantees(guarantees, path?, phone?)` | What an agent application promises: only the guarantees this contract names, each declared by presence and never `false`. Given the login's phone, the mute promise is held to it: `muteUnavailableOnHold` required of a softphone login's agent application and refused of any other (`host.guarantee.muteUnavailableOnHold.required` / `.unexpected`). The harness validates the guarantees of whatever agent application a test hands the adapter, against the phone it chose. |
3089
3088
  | `validateProviderTimeCheckPolicy(policy)` | Explicit optional polling settings, positive safe-integer durations and round-trip/timeout/interval ordering. |
3090
3089
  | `validateProviderTimeCheckRequest(request)` | Fresh-request shape; the agent application enforces actual uniqueness and outstanding-request lifetime. |
@@ -3093,18 +3092,23 @@ same exported checks are used by Omni and adapter tests so their interpretations
3093
3092
  | `validateHistoryReport(report, path?, manifest?, task?)` | What the agent application reports of a leg it performed, for an adapter to check before forwarding: a task, a step, when it began, a positive `seconds` where stated, and an explicit `ended` that carries the final duration. Given the manifest, a running report is refused unless it declares `runningStepReports`. Given the task, the agent application's own mute beginning on a `paused` task is refused (`historyReport.muted.held`): the Mute waits for resume. |
3094
3093
  | `validateHostReport(report)` | The agent application'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 agent application a test hands the adapter; `stillHost(report)` builds one that never changes. |
3095
3094
  | `validateNextCall(value)` | The agent's standing ask for the next call, on its own as `next-call` and `Snapshot.nextCall` carry it: since when, and nothing else (`nextCall.since`, `nextCall.field`). |
3096
- | `validateLinedUp(value, path?, levels?)` | A call lined up in the agent's own queue, on its own as `lined-up` and `Snapshot.linedUp` carry it: the caller under the same contact rules as a task's party, the queue's label, the waits, and `release` by presence (`linedUp.*`). |
3095
+ | `validateLinedUp(value, levels, path?)` | A call lined up in the agent's own queue, on its own as `lined-up` and `Snapshot.linedUp` carry it: the caller under the same contact rules as a task's party, the queue's label, the waits, and `release` by presence (`linedUp.*`). |
3097
3096
  | `validateLabels(value, path?)` | The customer's words on their own, as `labels-updated` and `Snapshot.labels` carry them: renames of the protocol's phases and steps only, and every word one text per well-formed language tag (`labels.*`). |
3098
3097
  | `validatePhoneState(value, path?, context?)` | The phone as the platform sees it, on its own as `phone-updated` and `Snapshot.phone` carry it: its status, its one active channel, its mute where a hardphone's is observed (`phone.*`). |
3099
3098
  | `validateHostMute(mute, softphone)` | What the agent application's Mute does, stated on a softphone login and nowhere else: `stream` or `station` (`host.mute`), required where the agent application holds a microphone (`host.mute.required`) and refused where it does not (`host.mute.unexpected`). The harness holds `ConnectContext.host.mute` to it. |
3100
3099
  | `validateLanguages(languages)` | The agent's languages a host states on both contexts: a non-empty list (`context.languages`) of well-formed BCP 47 tags (`context.languages.tag`), none twice ignoring case (`context.languages.unique`). The harness holds `ConnectContext.languages` to it. |
3101
3100
  | `validateLoginStore(store)` | The login's store the agent application hands every connection: an object with `get`, `set` and `delete` (`store.shape`, `store.get`, `.set`, `.delete`). The harness holds `ConnectContext.store` to it. |
3102
3101
  | `validateCapacity(capacity)` | What the agent application states as capacity: a whole number of zero or more (`capacity.count`), zero being agent application-stopped. The harness states one on connect and two, one and zero after the test, each answered `applied`, and holds any offer to the count in force (`stream.taskOffered.overCapacity`). |
3103
- | `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. |
3102
+ | `validateAuthenticationResult(result, method, path, { levels })` | 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. |
3104
3103
  | `validateRedirectUri(uri, path?)` | A host's fixed redirect URI in one of RFC 8252's forms: `https`, a reverse-domain private-use scheme, or loopback http to `127.0.0.1` or `[::1]`, with no fragment (`redirectUri.*`). |
3105
- | `validateTaskCommand(command, task?)` | What a command needs to be issuable, against the task it names: its own shape -- a dial's `dialId`, a conference's item, a schedule's time, 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.interaction` for every control on the call or the conversation), and the state that has to stand: a lead requested, somebody else still on the call (`command.conference.remove.alone`), the caller off hold before the agent's part ends (`command.endCall.held`). An agent application validates before sending and an adapter before acting. |
3104
+ | `validateTaskCommand(command, task?, path, { levels })` | What a command needs to be issuable, against the task it names: its own shape -- a dial's `dialId`, a conference's item, a schedule's time, 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.interaction` for every control on the call or the conversation), and the state that has to stand: a lead requested, somebody else still on the call (`command.conference.remove.alone`), the caller off hold before the agent's part ends (`command.endCall.held`). An agent application validates before sending and an adapter before acting. |
3106
3105
  | `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. |
3107
- | `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. Omni applies it to every state a session publishes — the republished as much as the first. |
3106
+ | `validateAuthenticationState(state, path, { levels })` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. Omni applies it to every state a session publishes — the republished as much as the first. |
3107
+
3108
+ Every check that reads a level — a `lockedBy`, a `setBy` — is given `levels`, the ids of the
3109
+ manifest's `orgLevels`, and refuses any other; `validateSnapshot` and `validateEventEnvelope` take
3110
+ them from the manifest they are given, and `validateTeamCommand` and `validateBreakCommand` from
3111
+ their context's `levels` (`team.command.levels`, `break.command.levels`). No check guesses a ladder.
3108
3112
 
3109
3113
  Each returns `ProtocolViolation[]` rather than throwing, so a caller can report every problem at
3110
3114
  once. A violation carries a stable `rule` id such as `task.browser.url.scheme`, the `path` it was
@@ -3301,7 +3305,7 @@ cannot be established from TypeScript structure alone.
3301
3305
  | `assertTaskCapabilityWithdrawal(tasks, manifest, command)` | A capability withdrawn by a republish of the task is gone from the task: every task in the sequence is validated, all carry the offer's id, at least one capability the offer declared is absent at the end (a locked control is present, not withdrawn), and `command` is clean against the first task and refused against the last for want of a withdrawn capability and nothing else. Pair it with `assertCommandRefusedAfterWithdrawal` on the provider's answer. |
3302
3306
  | `assertCommandRefusedAfterWithdrawal(result)` | A command that arrives after its capability was withdrawn fails with `omni.capability-not-enabled`, named by the provider. The same assertion serves a command the provider never supported under a capability it declares. |
3303
3307
  | `assertReached(result, subjects)` | The exercise met every subject named; throws listing those it did not. Pair it with a clean `testAdapter` result. |
3304
- | `assertAuthenticationRestoreAndExpiry(states)` | A restored authenticated session can refresh and ends in expiry. Every state is validated. |
3308
+ | `assertAuthenticationRestoreAndExpiry(states, manifest)` | A restored authenticated session can refresh and ends in expiry. Every state is validated, against the manifest's levels. |
3305
3309
  | `assertReconnectWithMissedAssignments(before, reconnect, ids)` | A reconnect snapshot restores assignments received while offline. |
3306
3310
  | `stillHost(report?, guarantees?, mute?)` | An agent application that reports one thing and never changes, for a test context: `{ online: true }` by default, a report with audio for a softphone voice adapter, and never for a hardphone. |
3307
3311
  | `TaskStream`, `BreakStream` | The cross-event models the harness applies after the connect snapshot, exported for an agent application that wants the same rules at its boundary: `seed(snapshot)`, then `apply(envelope)` returns the violations. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.127",
3
+ "version": "0.1.129",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",