@xema/omni-protocol 0.1.19 → 0.1.21

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
@@ -111,6 +111,8 @@ export interface Manifest<C extends Channel = Channel> {
111
111
  phaseLabels?: TaskPhaseLabels;
112
112
  /** Keyed by `taskType`. An entry replaces the channel default outright rather than merging. */
113
113
  taskTypePresentation?: Record<string, TaskTypePresentation>;
114
+ /** The organisation's whole ladder, stated outright, `person` included. Omitted for the typical four, `DEFAULT_TIERS`. */
115
+ orgTiers?: TierDeclaration[];
114
116
  }
115
117
  export interface SecretStore {
116
118
  get(key: string): Promise<string | undefined>;
@@ -145,6 +147,8 @@ export interface TeamCapabilities {
145
147
  breakControl?: true;
146
148
  /** This lead may join a member's call on request. Requires `executeTeamConsult`. */
147
149
  consultControl?: true;
150
+ /** This lead sets the team's policy per capability -- on, off, or the agent's -- within what the queue allows. Requires `executeTeamPolicy`. */
151
+ policyControl?: true;
148
152
  }
149
153
  /**
150
154
  * What this login may do, beyond any one task. It travels with the identity because it is part
@@ -154,6 +158,8 @@ export interface TeamCapabilities {
154
158
  export interface SessionCapabilities {
155
159
  /** This login may request a break. Requires the four break methods. */
156
160
  breaks?: true;
161
+ /** The choices the team left to this person, with where each stands. Omitted when there are none. Requires `setPreference`. */
162
+ preferences?: AgentPreference[];
157
163
  /** This login leads a team: a `TeamRoster` is published to it on every snapshot, `[]` included. */
158
164
  team?: TeamCapabilities;
159
165
  }
@@ -286,7 +292,13 @@ export type HostAudioOutput = {
286
292
  * network. Omni reports it; the adapter decides what any of it means for its platform and what
287
293
  * to relay. `audio` is present on a voice connection.
288
294
  */
295
+ /** What Omni's own chrome shows of a task browser's URL: nothing, the domain, or all of it. */
296
+ export type UrlVisibility = "full" | "domain" | "hidden";
289
297
  export interface HostReport {
298
+ /** What the agent can see of a task browser's URL in Omni's chrome. Task browsers only; the personal browser is the agent's. */
299
+ browsers: {
300
+ urlVisibility: UrlVisibility;
301
+ };
290
302
  /** Whether the host has a network interface up. Not a claim that anything is reachable: the adapter knows its own platform's reachability better than the host does. */
291
303
  online: boolean;
292
304
  audio?: {
@@ -317,8 +329,10 @@ export type ConnectionStatus = "connecting" | "active" | "error";
317
329
  /** Every field is optional: a provider sends what it knows and omits what it does not. */
318
330
  export interface Contact {
319
331
  name?: string;
320
- number?: string;
321
- email?: string;
332
+ /** The number, or `{ lockedBy }` where the queue says the agent may not see it: the last digits or nothing are sent, never a flag to honour. */
333
+ number?: Lockable<string>;
334
+ /** The email, or `{ lockedBy }` where the agent may not see it; it identifies a person as a number does. */
335
+ email?: Lockable<string>;
322
336
  attributes?: Attribute[];
323
337
  }
324
338
  export interface ScheduledActivity {
@@ -356,6 +370,12 @@ export interface CustomCapability {
356
370
  kind: "button" | "toggle" | "menu-item";
357
371
  label: string;
358
372
  placement: "primary" | "secondary" | "overflow";
373
+ /** Where the control's work renders: inline in the workspace, or as a page of its own. Inline when absent. */
374
+ render?: "inline" | "page";
375
+ };
376
+ /** What the agent supplies before the action runs; the values travel on the custom command. */
377
+ prompt?: {
378
+ fields: CredentialField[];
359
379
  };
360
380
  }
361
381
  export interface SharedTaskCapabilities {
@@ -369,24 +389,24 @@ export interface SharedTaskCapabilities {
369
389
  * The channel arms are why `Task<"email">` rejects `hold` at compile time rather than at runtime.
370
390
  */
371
391
  export type TaskCapabilities<C extends Channel = Channel> = C extends "voice" ? SharedTaskCapabilities & {
372
- decline?: true;
373
- mute?: true;
374
- hold?: true;
375
- agentDisconnect?: true;
392
+ decline?: Lockable<true>;
393
+ mute?: Lockable<true>;
394
+ hold?: Lockable<true>;
395
+ agentDisconnect?: Lockable<true>;
376
396
  /** Reach the party again while `completing`; the task returns to `in-progress`. */
377
- callback?: true;
378
- blindTransfer?: true | DestinationDirectory;
397
+ callback?: Lockable<true>;
398
+ blindTransfer?: Lockable<true | DestinationDirectory>;
379
399
  /** Park the customer and call a destination first; then `complete` or `cancel`. */
380
- consultTransfer?: true | DestinationDirectory;
400
+ consultTransfer?: Lockable<true | DestinationDirectory>;
381
401
  /** Ask a lead to join this call, with a note. The lead's decision arrives on `Task.lead`. */
382
- consultLead?: true;
383
- conference?: true | DestinationDirectory;
384
- recording?: true;
402
+ consultLead?: Lockable<true>;
403
+ conference?: Lockable<true | DestinationDirectory>;
404
+ recording?: Lockable<true>;
385
405
  } : C extends "chat" ? SharedTaskCapabilities & {
386
- reject?: true;
387
- hold?: true;
406
+ reject?: Lockable<true>;
407
+ hold?: Lockable<true>;
388
408
  } : SharedTaskCapabilities & {
389
- reject?: true;
409
+ reject?: Lockable<true>;
390
410
  };
391
411
  /**
392
412
  * How a reusing browser's session is keyed.
@@ -508,6 +528,50 @@ export interface TaskAssisting {
508
528
  note?: string;
509
529
  since: IsoTimestamp;
510
530
  }
531
+ /**
532
+ * A tier of the organisation's structure, by the id its manifest declares -- or one of the four
533
+ * a typical organisation has, `DEFAULT_TIERS`, when the manifest declares no ladder. The protocol
534
+ * never describes the chain: which tiers a person passes through is the structure's to know.
535
+ */
536
+ export type Tier = string;
537
+ /** A tier the structure has, with the label a desk shows for "who decided". */
538
+ export interface TierDeclaration {
539
+ id: Tier;
540
+ label: string;
541
+ }
542
+ /**
543
+ * The tiers a typical organisation has: the ladder in force when a manifest declares no
544
+ * `orgTiers`. A manifest that declares any states its whole ladder outright.
545
+ */
546
+ export declare const DEFAULT_TIERS: readonly [{
547
+ readonly id: "org";
548
+ readonly label: "Your organisation";
549
+ }, {
550
+ readonly id: "site";
551
+ readonly label: "Your site";
552
+ }, {
553
+ readonly id: "team";
554
+ readonly label: "Your team";
555
+ }, {
556
+ readonly id: "person";
557
+ readonly label: "You";
558
+ }];
559
+ /** The ladder in force for a manifest: exactly what it declares, or the defaults when it declares none. */
560
+ export declare function effectiveTiers(declared: readonly TierDeclaration[] | undefined): TierDeclaration[];
561
+ /**
562
+ * Something the queue could allow, locked above the person: the tier that made it unchangeable,
563
+ * and why if they said. `person` never locks their own value, and the queue is not a tier --
564
+ * what the queue does not allow at all is simply absent.
565
+ */
566
+ export interface Locked {
567
+ lockedBy: Tier;
568
+ reason?: string;
569
+ }
570
+ /**
571
+ * A value, or `{ lockedBy }` in its place: present without permission, saying whose. `lockedBy`
572
+ * is the discriminant: a value that carries it is the lock, so no `T` may carry that key.
573
+ */
574
+ export type Lockable<T> = T | Locked;
511
575
  export type Task<C extends Channel = Channel> = {
512
576
  id: TaskId;
513
577
  title: string;
@@ -804,6 +868,25 @@ export interface TeamRoster {
804
868
  members: TeamMember[];
805
869
  /** Omitted when the login lacks `team.consultControl`; `[]` when nobody is asking. */
806
870
  requests?: LeadRequest[];
871
+ /** The team's policy per capability, as it stands. Present exactly when the login declares `team.policyControl`. */
872
+ policies?: TeamPolicies;
873
+ }
874
+ /** A capability a team policy can name: any task control, new call, or a skill by its provider id. */
875
+ export type PolicyKey = Exclude<keyof TaskCapabilities<"voice">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"chat">, keyof SharedTaskCapabilities> | Exclude<keyof TaskCapabilities<"email">, keyof SharedTaskCapabilities> | "dial" | `skill:${string}`;
876
+ /** On for everyone, off for everyone, or the agent's own choice. Only `hold`, `mute` and skills may be `agent`. */
877
+ export type TeamPolicySetting = "on" | "off" | "agent";
878
+ /** One policy as the lead sees it: the setting, who set it, and `lockedBy` when a tier above the team made it theirs to keep. */
879
+ export interface TeamPolicy extends Resolved {
880
+ setting: TeamPolicySetting;
881
+ }
882
+ export type TeamPolicies = Partial<Record<PolicyKey, TeamPolicy>>;
883
+ export type TeamPolicyCommand = {
884
+ type: "set";
885
+ capability: PolicyKey;
886
+ setting: TeamPolicySetting;
887
+ };
888
+ export interface TeamPolicyCommandRequest {
889
+ command: TeamPolicyCommand;
807
890
  }
808
891
  export type TeamConsultCommand = {
809
892
  type: "join";
@@ -859,6 +942,50 @@ export type OpenMediaResult = {
859
942
  failure: ProtocolFailure;
860
943
  };
861
944
  /** The provider's complete state at one moment. It replaces what Omni holds; never a patch. */
945
+ /**
946
+ * What the team may leave to the person: a capability by its own name -- `hold`, `mute` -- or a
947
+ * skill by its provider id. The same key as in `Task.capabilities`, because it is the same
948
+ * capability seen at another tier. Callback and new call are never the person's; they are the
949
+ * team's, on or off, within what the queue allows.
950
+ */
951
+ export type PreferenceId = "hold" | "mute" | `skill:${string}`;
952
+ /**
953
+ * Who stated a value as it stands: a tier -- `person` among them -- or `provisioning`, the
954
+ * protocol's own word for "no tier has said anything and the provider's default applies".
955
+ * Nothing is hidden for want of a row.
956
+ */
957
+ export type SetBy = Tier | "provisioning";
958
+ /** What every resolved value carries: who set it, and who locked it if anyone did. */
959
+ export interface Resolved {
960
+ setBy: SetBy;
961
+ lockedBy?: Tier;
962
+ /** Given with `lockedBy`, where whoever locked it said why. */
963
+ reason?: string;
964
+ }
965
+ /**
966
+ * One choice the team may leave to the person, with where it stands and who set it. The provider
967
+ * keeps it: it is the person's across sessions, written through `setPreference`. Listed whether
968
+ * or not anyone has stated it, and even when a tier above has since locked it.
969
+ */
970
+ export interface AgentPreference extends Resolved {
971
+ id: PreferenceId;
972
+ label: string;
973
+ enabled: boolean;
974
+ }
975
+ /** The person's act: set their own value, or give it up and inherit again. */
976
+ export type SetPreferenceRequest = {
977
+ id: PreferenceId;
978
+ enabled: boolean;
979
+ } | {
980
+ id: PreferenceId;
981
+ inherit: true;
982
+ };
983
+ export type PreferenceResult = {
984
+ status: "applied";
985
+ } | {
986
+ status: "failed";
987
+ failure: ProtocolFailure;
988
+ };
862
989
  export interface Snapshot<C extends Channel = Channel> {
863
990
  status: ConnectionStatus;
864
991
  sessionId: string;
@@ -976,8 +1103,12 @@ export interface Connection<C extends Channel = Channel> {
976
1103
  executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
977
1104
  /** Required when the login declares `capabilities.team.consultControl`. */
978
1105
  executeTeamConsult?(request: TeamConsultCommandRequest): Promise<TeamCommandResult>;
1106
+ /** Required when the login declares `capabilities.team.policyControl`. */
1107
+ executeTeamPolicy?(request: TeamPolicyCommandRequest): Promise<TeamCommandResult>;
979
1108
  /** Required of every voice adapter: all voice audio lands in Omni. */
980
1109
  openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
1110
+ /** Required when the login declares `capabilities.preferences`: the person's own choice, kept by the provider and republished as `authenticated`. */
1111
+ setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
981
1112
  }
982
1113
  export interface Adapter<C extends Channel = Channel> {
983
1114
  manifest: Manifest<C>;
package/dist/index.js CHANGED
@@ -50,6 +50,20 @@ export function isAllowedBrowserUrl(url) {
50
50
  return false;
51
51
  }
52
52
  }
53
+ /**
54
+ * The tiers a typical organisation has: the ladder in force when a manifest declares no
55
+ * `orgTiers`. A manifest that declares any states its whole ladder outright.
56
+ */
57
+ export const DEFAULT_TIERS = [
58
+ { id: "org", label: "Your organisation" },
59
+ { id: "site", label: "Your site" },
60
+ { id: "team", label: "Your team" },
61
+ { id: "person", label: "You" },
62
+ ];
63
+ /** The ladder in force for a manifest: exactly what it declares, or the defaults when it declares none. */
64
+ export function effectiveTiers(declared) {
65
+ return [...(declared ?? DEFAULT_TIERS)];
66
+ }
53
67
  // ---------------------------------------------------------------------------
54
68
  // Task commands.
55
69
  // ---------------------------------------------------------------------------
package/dist/testing.d.ts CHANGED
@@ -7,7 +7,7 @@ export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation }
7
7
  * rules -- each optional part of a task, each optional part of the break state and roster, each
8
8
  * declared contribution, and each event type.
9
9
  */
10
- declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.consultation", "task.lead", "task.assisting", "task.dispositions", "task.destinations", "task.custom", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities"];
10
+ declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.handlingHistory", "task.consultation", "task.lead", "task.assisting", "task.dispositions", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.imposed", "team.members", "team.requests", "contacts", "scheduledActivities", "team.policies"];
11
11
  export type ContractSubject = (typeof STATE_SUBJECTS)[number] | `event.${ProviderEvent["type"]}`;
12
12
  export interface AdapterContractResult {
13
13
  events: ProviderEventEnvelope[];
package/dist/testing.js CHANGED
@@ -1,4 +1,4 @@
1
- import { browserSessionKey, sameCapabilities, } from "./index.js";
1
+ import { browserSessionKey, effectiveTiers, sameCapabilities, } from "./index.js";
2
2
  import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateHostReport, validateManifest, validateResult, validateSnapshot, } from "./validation.js";
3
3
  export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
4
4
  /**
@@ -18,12 +18,14 @@ const STATE_SUBJECTS = [
18
18
  "task.dispositions",
19
19
  "task.destinations",
20
20
  "task.custom",
21
+ "task.locked",
21
22
  "break.reasons",
22
23
  "break.imposed",
23
24
  "team.members",
24
25
  "team.requests",
25
26
  "contacts",
26
27
  "scheduledActivities",
28
+ "team.policies",
27
29
  ];
28
30
  // Pinned to the event union the way validation pins its closed sets: a type added to
29
31
  // `ProviderEvent` without a row here, or a row it lacks, is a compile error.
@@ -66,6 +68,8 @@ function observeTask(value, seen) {
66
68
  }
67
69
  if (some(capabilities.custom))
68
70
  seen.add("task.custom");
71
+ if (Object.values(capabilities).some(declared => isRecord(declared) && declared.lockedBy !== undefined))
72
+ seen.add("task.locked");
69
73
  }
70
74
  function observeBreak(value, seen) {
71
75
  if (!isRecord(value))
@@ -82,6 +86,8 @@ function observeTeam(value, seen) {
82
86
  seen.add("team.members");
83
87
  if (some(value.requests))
84
88
  seen.add("team.requests");
89
+ if (isRecord(value.policies) && Object.keys(value.policies).length > 0)
90
+ seen.add("team.policies");
85
91
  }
86
92
  function observeSnapshot(value, seen) {
87
93
  if (!isRecord(value))
@@ -163,7 +169,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
163
169
  let disconnectWasClean = false;
164
170
  try {
165
171
  authenticationState = await authentication.state();
166
- violations.push(...validateAuthenticationState(authenticationState));
172
+ const tiers = effectiveTiers(adapter.manifest.orgTiers).map(tier => tier.id);
173
+ violations.push(...validateAuthenticationState(authenticationState, "authentication", { tiers }));
167
174
  if (authenticationState.status !== "authenticated") {
168
175
  throw new Error(`Adapter contract exercise requires authenticated test state, received ${authenticationState.status}`);
169
176
  }
@@ -212,9 +219,13 @@ export async function exerciseAdapter(adapter, context, options = {}) {
212
219
  requireMethod(on, "executeTeamBreak", "the login declares capabilities.team.breakControl");
213
220
  if (capabilities.team?.consultControl === true)
214
221
  requireMethod(on, "executeTeamConsult", "the login declares capabilities.team.consultControl");
222
+ if (capabilities.team?.policyControl === true)
223
+ requireMethod(on, "executeTeamPolicy", "the login declares capabilities.team.policyControl");
224
+ if (some(capabilities.preferences))
225
+ requireMethod(on, "setPreference", "the login declares capabilities.preferences");
215
226
  };
216
227
  unsubscribeAuthentication = authentication.subscribe(state => {
217
- const own = validateAuthenticationState(state);
228
+ const own = validateAuthenticationState(state, "authentication", { tiers });
218
229
  violations.push(...own);
219
230
  if (own.length > 0)
220
231
  return;
@@ -723,7 +734,7 @@ export function assertMediaFollowsTheTask(envelopes, snapshot) {
723
734
  assertNoViolations(found, "The media follows the task");
724
735
  }
725
736
  /** A host that reports one thing and never changes: what most adapter tests hand `exerciseAdapter`. */
726
- export function stillHost(report = { online: true }) {
737
+ export function stillHost(report = { online: true, browsers: { urlVisibility: "hidden" } }) {
727
738
  return { report: () => report, subscribe: () => () => undefined };
728
739
  }
729
740
  const usableLogin = (status) => status === "authenticated" || status === "refreshing";
@@ -13,6 +13,8 @@ export declare function validateManifest(manifest: unknown, path?: string): Prot
13
13
  export interface TaskValidationContext {
14
14
  /** The provider's channel, from its manifest. A task must agree with it. */
15
15
  channel: string;
16
+ /** The tier ids in force, from the manifest. The defaults when absent. */
17
+ tiers?: readonly string[];
16
18
  }
17
19
  export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
18
20
  /**
@@ -30,6 +32,8 @@ export interface ReaderContext {
30
32
  * snapshot carries a roster, nobody else's does, and `requests` need `team.consultControl`.
31
33
  */
32
34
  capabilities?: SessionCapabilities;
35
+ /** The tier ids in force. Filled from the manifest by `validateSnapshot` and `validateEventEnvelope`; the defaults otherwise. */
36
+ tiers?: readonly string[];
33
37
  /** The login's `sessionId`. A snapshot or event naming another belongs to a login that is gone. */
34
38
  sessionId?: string;
35
39
  /** `ConnectContext.autoAcceptTasks` as sent, absent meaning `true`: whether `task-offered` carries an `acceptanceMode`. */
@@ -45,7 +49,7 @@ export declare function validateEventEnvelope(envelope: unknown, manifest: unkno
45
49
  */
46
50
  export declare function validateHostReport(report: unknown, path?: string): ProtocolViolation[];
47
51
  /** The connection methods whose results `validateResult` knows. */
48
- export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia";
52
+ export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" | "commitBreak" | "cancelBreak" | "endBreak" | "executeTeamBreak" | "executeTeamConsult" | "openMedia" | "setPreference" | "executeTeamPolicy";
49
53
  /**
50
54
  * Validates what a connection method answered. A result is untrusted for the same reason a
51
55
  * snapshot is: it comes from an adapter that may be compiled against another version, and Omni
@@ -53,4 +57,9 @@ export type ResultMethod = "execute" | "dial" | "setCapacity" | "requestBreak" |
53
57
  * failure, a success carrying one, or an `omni.` code the contract lacks are each refused.
54
58
  */
55
59
  export declare function validateResult(result: unknown, method: ResultMethod, path?: string): ProtocolViolation[];
56
- export declare function validateAuthenticationState(state: unknown, path?: string): ProtocolViolation[];
60
+ /** What a login is validated against beyond its own shape. */
61
+ export interface LoginValidationContext {
62
+ /** The tier ids in force, from the manifest. The defaults when absent. */
63
+ tiers?: readonly string[];
64
+ }
65
+ export declare function validateAuthenticationState(state: unknown, path?: string, context?: LoginValidationContext): ProtocolViolation[];
@@ -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 { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, negotiateProtocolVersion, } from "./index.js";
12
+ import { ALLOWED_BROWSER_URL_SCHEMES, BREAK_KINDS, BROWSER_ISOLATION_SCHEMES, IDLE_CAPABILITIES, OMNI_FAILURE_CODES, OMNI_SUPPORTED_PROTOCOL_VERSIONS, negotiateProtocolVersion, DEFAULT_TIERS, effectiveTiers, } from "./index.js";
13
13
  export class ProtocolConformanceError extends Error {
14
14
  violations;
15
15
  constructor(violations, summary = "Adapter violates the Omni protocol") {
@@ -69,14 +69,14 @@ const DIAL_DESTINATION_POLICIES = membersOf({ "contacts-only": true, "any-number
69
69
  const SNAPSHOT_REASONS = membersOf({
70
70
  reconnected: true, "provider-requested": true,
71
71
  });
72
- const SESSION_CAPABILITIES = membersOf({ breaks: true, team: true });
72
+ const SESSION_CAPABILITIES = membersOf({ breaks: true, team: true, preferences: true });
73
73
  const MEMBER_BREAKS = membersOf({
74
74
  "awaiting-decision": true, granted: true, "starting-after-task": true,
75
75
  });
76
76
  const OFFERABLE_PHASES = membersOf({
77
77
  pending: true, confirmed: true, preparing: true,
78
78
  });
79
- const TEAM_CAPABILITIES = membersOf({ breakControl: true, consultControl: true });
79
+ const TEAM_CAPABILITIES = membersOf({ breakControl: true, consultControl: true, policyControl: true });
80
80
  const COMPLETED_BY = membersOf({ agent: true, provider: true });
81
81
  const EXPIRABLE_PHASES = membersOf({
82
82
  pending: true, confirmed: true, preparing: true,
@@ -164,15 +164,19 @@ export function validateContact(contact, path = "contact") {
164
164
  validateContactInto(contact, path, into);
165
165
  return into.violations;
166
166
  }
167
- function validateContactInto(contact, path, into) {
167
+ function validateContactInto(contact, path, into, tiers) {
168
168
  if (!isPlainObject(contact)) {
169
169
  into.add("contact.shape", path, "a contact must be an object");
170
170
  return;
171
171
  }
172
172
  for (const field of ["name", "number", "email"]) {
173
- if (contact[field] !== undefined) {
174
- into.filled(contact[field], `contact.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
173
+ if (contact[field] === undefined)
174
+ continue;
175
+ if ((field === "number" || field === "email") && isLocked(contact[field])) {
176
+ validateLockedInto(contact[field], `contact.${field}.locked`, `${path}.${field}`, tiers, into);
177
+ continue;
175
178
  }
179
+ into.filled(contact[field], `contact.${field}`, `${path}.${field}`, `${field} must not be empty when present`);
176
180
  }
177
181
  validateAttributes(contact.attributes, `${path}.attributes`, into);
178
182
  }
@@ -307,6 +311,37 @@ export function validateManifest(manifest, path = "manifest") {
307
311
  }
308
312
  }
309
313
  }
314
+ if (manifest.orgTiers !== undefined) {
315
+ if (!Array.isArray(manifest.orgTiers)) {
316
+ into.add("manifest.orgTiers.shape", `${path}.orgTiers`, "orgTiers must be an array when present");
317
+ }
318
+ else {
319
+ const ids = new Set();
320
+ let wellFormed = true;
321
+ manifest.orgTiers.forEach((tier, index) => {
322
+ const at = `${path}.orgTiers[${index}]`;
323
+ if (!isPlainObject(tier)) {
324
+ into.add("manifest.orgTier.shape", at, "each tier must be an object with an id and a label");
325
+ wellFormed = false;
326
+ return;
327
+ }
328
+ if (into.filled(tier.id, "manifest.orgTier.id", `${at}.id`, "a tier needs an id")) {
329
+ if (ids.has(tier.id)) {
330
+ into.add("manifest.orgTier.unique", `${at}.id`, `duplicate tier: ${tier.id}`);
331
+ wellFormed = false;
332
+ }
333
+ ids.add(tier.id);
334
+ }
335
+ else
336
+ wellFormed = false;
337
+ if (!into.filled(tier.label, "manifest.orgTier.label", `${at}.label`, "a tier needs the label a desk shows for it"))
338
+ wellFormed = false;
339
+ });
340
+ if (wellFormed && !ids.has("person")) {
341
+ into.add("manifest.orgTiers.person", `${path}.orgTiers`, "a declared ladder states the whole ladder and must include person, the subject of every resolution");
342
+ }
343
+ }
344
+ }
310
345
  if (manifest.taskTypePresentation !== undefined) {
311
346
  if (!isPlainObject(manifest.taskTypePresentation)) {
312
347
  into.add("manifest.taskTypePresentation.shape", `${path}.taskTypePresentation`, "taskTypePresentation must be an object when present");
@@ -427,8 +462,73 @@ function validateCustomCapabilities(value, path, into) {
427
462
  into.oneOf(custom.ui.kind, CUSTOM_UI_KINDS, "task.custom.ui.kind", `${at}.ui.kind`);
428
463
  into.filled(custom.ui.label, "task.custom.ui.label", `${at}.ui.label`, "a custom control needs a label");
429
464
  into.oneOf(custom.ui.placement, CUSTOM_UI_PLACEMENTS, "task.custom.ui.placement", `${at}.ui.placement`);
465
+ if (custom.ui.render !== undefined)
466
+ into.oneOf(custom.ui.render, CUSTOM_RENDERS, "task.custom.ui.render", `${at}.ui.render`);
467
+ if (custom.prompt !== undefined) {
468
+ if (!isPlainObject(custom.prompt) || !Array.isArray(custom.prompt.fields)) {
469
+ into.add("task.custom.prompt.shape", `${at}.prompt`, "a prompt is an object with the fields the agent fills");
470
+ }
471
+ else {
472
+ into.require(custom.prompt.fields.length > 0, "task.custom.prompt.fields", `${at}.prompt.fields`, "a prompt with no fields asks for nothing; omit it");
473
+ custom.prompt.fields.forEach((field, fieldIndex) => {
474
+ const where = `${at}.prompt.fields[${fieldIndex}]`;
475
+ if (!isPlainObject(field)) {
476
+ into.add("task.custom.prompt.field.shape", where, "each prompt field must be an object");
477
+ return;
478
+ }
479
+ into.filled(field.name, "task.custom.prompt.field.name", `${where}.name`, "a prompt field needs a name");
480
+ into.filled(field.label, "task.custom.prompt.field.label", `${where}.label`, "a prompt field needs a label");
481
+ into.oneOf(field.type, CREDENTIAL_FIELD_TYPES, "task.custom.prompt.field.type", `${where}.type`);
482
+ });
483
+ }
484
+ }
430
485
  });
431
486
  }
487
+ const DEFAULT_TIER_IDS = DEFAULT_TIERS.map(tier => tier.id);
488
+ const POLICY_SETTINGS = membersOf({ on: true, off: true, agent: true });
489
+ const POLICY_KEYS = new Set([
490
+ ...TASK_CAPABILITIES.voice, ...TASK_CAPABILITIES.chat, ...TASK_CAPABILITIES.email, "dial",
491
+ ].filter(name => name !== "browsers" && name !== "dispositions" && name !== "custom"));
492
+ const AGENT_SETTABLE = /^(hold|mute|skill:.+)$/;
493
+ const isLocked = (value) => isPlainObject(value) && value.lockedBy !== undefined;
494
+ /** The tier ids in force: the manifest's, or the defaults when the caller holds no manifest. */
495
+ const tierIds = (tiers) => tiers ?? DEFAULT_TIER_IDS;
496
+ /** `lockedBy`: a declared tier other than `person`, who never locks their own value. */
497
+ function validateLockedByInto(value, rule, path, tiers, into) {
498
+ if (!into.filled(value, rule, path, "lockedBy names the tier that locked it"))
499
+ return;
500
+ into.require(value !== "person", `${rule}.person`, path, "a person never locks their own value");
501
+ into.require(tierIds(tiers).includes(value), `${rule}.unknown`, path, `${String(value)} is not a tier this manifest declares: in force are ${tierIds(tiers).join(", ")}`);
502
+ }
503
+ /** `{ lockedBy, reason? }` standing in for a value: who locked it, and a reason if given. */
504
+ function validateLockedInto(value, rule, path, tiers, into) {
505
+ validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, tiers, into);
506
+ if (value.reason !== undefined)
507
+ into.filled(value.reason, `${rule}.reason`, `${path}.reason`, "a reason must not be empty when present");
508
+ }
509
+ /** What every resolved value carries: who set it, and who locked it if anyone. */
510
+ function validateResolvedInto(value, rule, path, tiers, into) {
511
+ if (into.filled(value.setBy, `${rule}.setBy`, `${path}.setBy`, "setBy names who stated the value: a tier, or provisioning")) {
512
+ into.require(value.setBy === "provisioning" || tierIds(tiers).includes(value.setBy), `${rule}.setBy.unknown`, `${path}.setBy`, `${String(value.setBy)} is neither provisioning nor a tier this manifest declares`);
513
+ }
514
+ if (value.lockedBy !== undefined)
515
+ validateLockedByInto(value.lockedBy, `${rule}.lockedBy`, `${path}.lockedBy`, tiers, into);
516
+ if (value.reason !== undefined) {
517
+ into.filled(value.reason, `${rule}.reason`, `${path}.reason`, "a reason must not be empty when present");
518
+ into.require(value.lockedBy !== undefined, `${rule}.reason.unexpected`, `${path}.reason`, "a reason goes with lockedBy: it says why it was locked");
519
+ }
520
+ }
521
+ /** The tier ids a manifest puts in force, for validators that receive one. */
522
+ function manifestTiers(manifest) {
523
+ if (!isPlainObject(manifest))
524
+ return undefined;
525
+ const declared = Array.isArray(manifest.orgTiers)
526
+ ? manifest.orgTiers.filter((tier) => isPlainObject(tier) && typeof tier.id === "string")
527
+ : undefined;
528
+ return effectiveTiers(declared).map(tier => tier.id);
529
+ }
530
+ const CUSTOM_RENDERS = membersOf({ inline: true, page: true });
531
+ const CREDENTIAL_FIELD_TYPES = membersOf({ text: true, password: true });
432
532
  function validateBrowsers(value, path, into) {
433
533
  if (!Array.isArray(value)) {
434
534
  into.add("task.browsers.shape", path, "browsers must be an array");
@@ -633,7 +733,7 @@ function validateTaskInto(task, context, path, into) {
633
733
  into.filled(task.reference, "task.reference", `${path}.reference`, "a reference must not be empty when present");
634
734
  }
635
735
  if (task.contact !== undefined)
636
- validateContactInto(task.contact, `${path}.contact`, into);
736
+ validateContactInto(task.contact, `${path}.contact`, into, context.tiers);
637
737
  validateBrowsers(task.browsers, `${path}.browsers`, into);
638
738
  validateTaskAttributes(task.attributes, `${path}.attributes`, into);
639
739
  validateHandlingHistory(task.handlingHistory, `${path}.handlingHistory`, into);
@@ -660,6 +760,14 @@ function validateTaskInto(task, context, path, into) {
660
760
  continue;
661
761
  if (!into.require(allowed.includes(name), "task.capability.channel", `${path}.capabilities.${name}`, `a ${context.channel} task may not declare ${name}`))
662
762
  continue;
763
+ // A control the queue could allow may stand locked in its place, saying whose. What the
764
+ // queue provides -- browsers, dispositions, custom controls -- is content, not a control.
765
+ if (isLocked(declared)) {
766
+ if (into.require(name !== "browsers" && name !== "dispositions" && name !== "custom", "task.capability.locked.unexpected", `${path}.capabilities.${name}`, `${name} is what the queue provides, not a control anyone locks`)) {
767
+ validateLockedInto(declared, "task.capability.locked", `${path}.capabilities.${name}`, context.tiers, into);
768
+ }
769
+ continue;
770
+ }
663
771
  switch (name) {
664
772
  case "dispositions":
665
773
  validateDispositions(declared, `${path}.capabilities.dispositions`, into);
@@ -780,6 +888,18 @@ function validateTeamRosterInto(roster, path, context, into) {
780
888
  if (context.capabilities !== undefined && context.capabilities.team === undefined) {
781
889
  into.add("team.unentitled", path, "a roster published to a login that does not declare capabilities.team: the login is the permission");
782
890
  }
891
+ // The team's policies travel with the roster exactly when the login may set them.
892
+ if (context.capabilities !== undefined) {
893
+ const may = context.capabilities.team?.policyControl === true;
894
+ if (may && roster.policies === undefined) {
895
+ into.add("team.policies.required", `${path}.policies`, "the login declares team.policyControl, so the roster carries the team's policies");
896
+ }
897
+ if (!may && roster.policies !== undefined) {
898
+ into.add("team.policies.capability", `${path}.policies`, "policies require team.policyControl on the login: a lead who may not set them has nothing to see");
899
+ }
900
+ }
901
+ if (roster.policies !== undefined)
902
+ validateTeamPoliciesInto(roster.policies, `${path}.policies`, into, context.tiers);
783
903
  if (roster.requests === undefined) {
784
904
  // `[]` says nobody is asking; omission says the lead may not be asked. A login that may be
785
905
  // asked therefore always carries the list.
@@ -855,6 +975,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
855
975
  return into.violations;
856
976
  }
857
977
  const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
978
+ const tiers = context.tiers ?? manifestTiers(manifest);
858
979
  into.oneOf(snapshot.status, CONNECTION_STATUSES, "snapshot.status", `${path}.status`);
859
980
  if (into.filled(snapshot.sessionId, "snapshot.sessionId", `${path}.sessionId`, "a snapshot needs the session id it belongs to")
860
981
  && context.sessionId !== undefined) {
@@ -868,7 +989,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
868
989
  const seen = new Set();
869
990
  let assisting;
870
991
  snapshot.tasks.forEach((task, index) => {
871
- validateTaskInto(task, { channel }, `${path}.tasks[${index}]`, into);
992
+ validateTaskInto(task, { channel, tiers }, `${path}.tasks[${index}]`, into);
872
993
  // A lead assists one call at a time.
873
994
  if (isPlainObject(task) && task.assisting !== undefined) {
874
995
  if (assisting !== undefined)
@@ -933,7 +1054,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
933
1054
  into.add("team.required", `${path}.team`, "the login declares capabilities.team, so every snapshot carries a roster: [] when nobody is in it");
934
1055
  }
935
1056
  if (snapshot.team !== undefined)
936
- validateTeamRosterInto(snapshot.team, `${path}.team`, context, into);
1057
+ validateTeamRosterInto(snapshot.team, `${path}.team`, { ...context, tiers }, into);
937
1058
  return into.violations;
938
1059
  }
939
1060
  // ---------------------------------------------------------------------------
@@ -1017,6 +1138,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1017
1138
  return into.violations;
1018
1139
  }
1019
1140
  const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
1141
+ const tiers = context.tiers ?? manifestTiers(manifest);
1020
1142
  into.filled(envelope.id, "event.id", `${path}.id`, "an event needs an id");
1021
1143
  if (into.filled(envelope.sessionId, "event.sessionId", `${path}.sessionId`, "an event needs the session id it belongs to")
1022
1144
  && context.sessionId !== undefined) {
@@ -1033,7 +1155,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1033
1155
  switch (event.type) {
1034
1156
  case "snapshot":
1035
1157
  into.oneOf(event.reason, SNAPSHOT_REASONS, "event.snapshot.reason", `${at}.reason`);
1036
- into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, context));
1158
+ into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, { ...context, tiers }));
1037
1159
  break;
1038
1160
  case "provider-status":
1039
1161
  into.oneOf(event.status, CONNECTION_STATUSES, "event.providerStatus.status", `${at}.status`);
@@ -1045,7 +1167,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1045
1167
  validateBreakState(event.break, `${at}.break`, into);
1046
1168
  break;
1047
1169
  case "task-offered":
1048
- validateTaskInto(event.task, { channel }, `${at}.task`, into);
1170
+ validateTaskInto(event.task, { channel, tiers }, `${at}.task`, into);
1049
1171
  // An offer introduces work that is not yet under way; work in progress arrives only on a snapshot.
1050
1172
  if (isPlainObject(event.task) && typeof event.task.phase === "string") {
1051
1173
  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}`);
@@ -1066,7 +1188,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1066
1188
  }
1067
1189
  break;
1068
1190
  case "task-updated":
1069
- validateTaskInto(event.task, { channel }, `${at}.task`, into);
1191
+ validateTaskInto(event.task, { channel, tiers }, `${at}.task`, into);
1070
1192
  break;
1071
1193
  case "task-media-ended":
1072
1194
  into.require(isTaskId(event.taskId), "event.taskMediaEnded.taskId", `${at}.taskId`, "a task id is required");
@@ -1088,7 +1210,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1088
1210
  validateProviderSummary(event.summary, `${at}.summary`, into);
1089
1211
  break;
1090
1212
  case "team-updated":
1091
- validateTeamRosterInto(event.team, `${at}.team`, context, into);
1213
+ validateTeamRosterInto(event.team, `${at}.team`, { ...context, tiers }, into);
1092
1214
  break;
1093
1215
  case "contacts-updated":
1094
1216
  into.require(idle.contacts === true, "event.contacts.capability", `${at}.contacts`, "contacts-updated requires the contacts idle capability");
@@ -1125,6 +1247,53 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
1125
1247
  // Results. A result crosses the same boundary a snapshot does, from an adapter that may be
1126
1248
  // compiled against another version, and Omni shows the agent what it says.
1127
1249
  // ---------------------------------------------------------------------------
1250
+ const PREFERENCE_ID = /^(hold|mute|skill:.+)$/;
1251
+ /** The choices left to the person, each with where it stands. */
1252
+ function validatePreferencesInto(value, path, into, tiers) {
1253
+ if (!Array.isArray(value)) {
1254
+ into.add("preferences.shape", path, "preferences must be an array");
1255
+ return;
1256
+ }
1257
+ const seen = new Set();
1258
+ value.forEach((preference, index) => {
1259
+ const at = `${path}[${index}]`;
1260
+ if (!isPlainObject(preference)) {
1261
+ into.add("preference.shape", at, "each preference must be an object");
1262
+ return;
1263
+ }
1264
+ if (into.require(typeof preference.id === "string" && PREFERENCE_ID.test(preference.id), "preference.id", `${at}.id`, "a preference is hold, mute, or skill:<id>: nothing else is the person's to set")) {
1265
+ if (seen.has(preference.id))
1266
+ into.add("preference.unique", `${at}.id`, `duplicate preference: ${preference.id}`);
1267
+ seen.add(preference.id);
1268
+ }
1269
+ into.filled(preference.label, "preference.label", `${at}.label`, "a preference needs a label");
1270
+ into.require(typeof preference.enabled === "boolean", "preference.enabled", `${at}.enabled`, "a preference says where it stands");
1271
+ validateResolvedInto(preference, "preference", at, tiers, into);
1272
+ });
1273
+ }
1274
+ /** The team's policy per capability as the lead sees it: the setting, who set it, who locked it. */
1275
+ function validateTeamPoliciesInto(value, path, into, tiers) {
1276
+ if (!isPlainObject(value)) {
1277
+ into.add("team.policies.shape", path, "policies must be an object keyed by capability");
1278
+ return;
1279
+ }
1280
+ for (const [key, policy] of Object.entries(value)) {
1281
+ if (policy === undefined)
1282
+ continue;
1283
+ const at = `${path}.${key}`;
1284
+ if (!into.require(POLICY_KEYS.has(key) || /^skill:.+$/.test(key), "team.policy.key", at, `${key} is not a control a policy can name: a task control, dial, or skill:<id>`))
1285
+ continue;
1286
+ if (!isPlainObject(policy)) {
1287
+ into.add("team.policy.shape", at, "each policy carries its setting, who set it, and who locked it if anyone");
1288
+ continue;
1289
+ }
1290
+ if (into.oneOf(policy.setting, POLICY_SETTINGS, "team.policy.setting", `${at}.setting`)) {
1291
+ into.require(policy.setting !== "agent" || AGENT_SETTABLE.test(key), "team.policy.agent", `${at}.setting`, `${key} is the team's, on or off; only hold, mute and skills may be left to the person`);
1292
+ }
1293
+ validateResolvedInto(policy, "team.policy", at, tiers, into);
1294
+ into.require(policy.setBy !== "person", "team.policy.setBy", `${at}.setBy`, "a team policy is not set by a person");
1295
+ }
1296
+ }
1128
1297
  /** A `ProtocolFailure`, wherever one appears: on a result, or on a task's failed outcome. */
1129
1298
  function validateFailureInto(value, path, into) {
1130
1299
  if (!isPlainObject(value)) {
@@ -1144,6 +1313,7 @@ function validateFailureInto(value, path, into) {
1144
1313
  into.require(typeof value.retryAfterMs === "number" && Number.isFinite(value.retryAfterMs) && value.retryAfterMs >= 0, "failure.retryAfterMs", `${path}.retryAfterMs`, "retryAfterMs must be a non-negative number when present");
1145
1314
  }
1146
1315
  }
1316
+ const URL_VISIBILITIES = membersOf({ full: true, domain: true, hidden: true });
1147
1317
  const HOST_AUDIO_REASONS = membersOf({ "no-device": true, denied: true, "not-asked": true, "in-use": true, lost: true });
1148
1318
  const HOST_OUTPUT_REASONS = membersOf({ "no-device": true, lost: true });
1149
1319
  function validateUnavailable(value, rule, path, into) {
@@ -1166,6 +1336,12 @@ export function validateHostReport(report, path = "host") {
1166
1336
  return into.violations;
1167
1337
  }
1168
1338
  into.require(typeof report.online === "boolean", "host.online", `${path}.online`, "a host report says whether it has a network");
1339
+ if (!isPlainObject(report.browsers)) {
1340
+ into.add("host.browsers.shape", `${path}.browsers`, "a host report says what its chrome shows of a task browser's URL");
1341
+ }
1342
+ else {
1343
+ into.oneOf(report.browsers.urlVisibility, URL_VISIBILITIES, "host.browsers.urlVisibility", `${path}.browsers.urlVisibility`);
1344
+ }
1169
1345
  if (report.audio === undefined)
1170
1346
  return into.violations;
1171
1347
  if (!isPlainObject(report.audio)) {
@@ -1223,6 +1399,8 @@ const RESULT_STATUSES = {
1223
1399
  executeTeamBreak: { success: "applied", failure: "failed" },
1224
1400
  executeTeamConsult: { success: "applied", failure: "failed" },
1225
1401
  openMedia: { success: "opened", failure: "unavailable" },
1402
+ setPreference: { success: "applied", failure: "failed" },
1403
+ executeTeamPolicy: { success: "applied", failure: "failed" },
1226
1404
  };
1227
1405
  /**
1228
1406
  * Validates what a connection method answered. A result is untrusted for the same reason a
@@ -1267,7 +1445,7 @@ function validateUser(value, rule, path, into) {
1267
1445
  into.require(isUserId(value.id), `${rule}.id`, `${path}.id`, "an identity needs a provider-issued user id");
1268
1446
  into.filled(value.displayName, `${rule}.displayName`, `${path}.displayName`, "an identity needs a display name");
1269
1447
  }
1270
- function validateSessionCapabilitiesInto(value, path, into) {
1448
+ function validateSessionCapabilitiesInto(value, path, into, tiers) {
1271
1449
  if (!isPlainObject(value)) {
1272
1450
  into.add("authentication.capabilities.shape", path, "a usable login declares its capabilities: an object, {} when it has none");
1273
1451
  return;
@@ -1277,6 +1455,13 @@ function validateSessionCapabilitiesInto(value, path, into) {
1277
1455
  continue;
1278
1456
  if (!into.require(SESSION_CAPABILITIES.includes(name), "authentication.capability.unknown", `${path}.${name}`, `unsupported session capability: ${name}`))
1279
1457
  continue;
1458
+ if (name === "preferences") {
1459
+ if (Array.isArray(declared) && declared.length === 0) {
1460
+ into.add("authentication.capability.preferences.empty", `${path}.preferences`, "a login with nothing left to the person omits preferences rather than declaring an empty list");
1461
+ }
1462
+ validatePreferencesInto(declared, `${path}.preferences`, into, tiers);
1463
+ continue;
1464
+ }
1280
1465
  if (name === "team") {
1281
1466
  if (!isPlainObject(declared)) {
1282
1467
  into.add("authentication.capability.team.shape", `${path}.team`, "team names what the lead may do: an object, {} for a lead with no controls");
@@ -1294,7 +1479,7 @@ function validateSessionCapabilitiesInto(value, path, into) {
1294
1479
  into.require(declared === true, "authentication.capability.value", `${path}.${name}`, `${name} is declared by presence: send true or omit it`);
1295
1480
  }
1296
1481
  }
1297
- export function validateAuthenticationState(state, path = "authentication") {
1482
+ export function validateAuthenticationState(state, path = "authentication", context = {}) {
1298
1483
  const into = new Collector();
1299
1484
  if (!isPlainObject(state)) {
1300
1485
  into.add("authentication.shape", path, "an authentication state must be an object");
@@ -1307,7 +1492,7 @@ export function validateAuthenticationState(state, path = "authentication") {
1307
1492
  // may carry an identity. Anything else is a state claiming knowledge it does not have.
1308
1493
  if (state.status === "authenticated" || state.status === "refreshing") {
1309
1494
  validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
1310
- validateSessionCapabilitiesInto(state.capabilities, `${path}.capabilities`, into);
1495
+ validateSessionCapabilitiesInto(state.capabilities, `${path}.capabilities`, into, context.tiers);
1311
1496
  }
1312
1497
  else if (state.status === "expired") {
1313
1498
  if (state.identity !== undefined)
package/guide.md CHANGED
@@ -176,6 +176,7 @@ type Manifest<C extends Channel = Channel> = {
176
176
  idleCapabilities?: IdleCapabilities<C>;
177
177
  phaseLabels?: TaskPhaseLabels;
178
178
  taskTypePresentation?: Record<string, TaskTypePresentation>;
179
+ orgTiers?: TierDeclaration[];
179
180
  };
180
181
  ```
181
182
 
@@ -223,10 +224,12 @@ type AuthenticationContext = {
223
224
  type TeamCapabilities = {
224
225
  breakControl?: true;
225
226
  consultControl?: true;
227
+ policyControl?: true;
226
228
  };
227
229
 
228
230
  type SessionCapabilities = {
229
231
  breaks?: true;
232
+ preferences?: AgentPreference[];
230
233
  team?: TeamCapabilities;
231
234
  };
232
235
 
@@ -256,7 +259,10 @@ type HostAudioOutput =
256
259
  | { status: "ready" }
257
260
  | { status: "unavailable"; reason: HostOutputUnavailableReason; failure: ProtocolFailure };
258
261
 
262
+ type UrlVisibility = "full" | "domain" | "hidden";
263
+
259
264
  type HostReport = {
265
+ browsers: { urlVisibility: UrlVisibility };
260
266
  online: boolean;
261
267
  audio?: {
262
268
  input: HostAudioInput;
@@ -328,6 +334,30 @@ type AuthenticationSession = {
328
334
  ### Provider state
329
335
 
330
336
  ```ts
337
+ type PreferenceId = "hold" | "mute" | `skill:${string}`;
338
+
339
+ type SetBy = Tier | "provisioning";
340
+
341
+ type Resolved = {
342
+ setBy: SetBy;
343
+ lockedBy?: Tier;
344
+ reason?: string;
345
+ };
346
+
347
+ type AgentPreference = Resolved & {
348
+ id: PreferenceId;
349
+ label: string;
350
+ enabled: boolean;
351
+ };
352
+
353
+ type SetPreferenceRequest =
354
+ | { id: PreferenceId; enabled: boolean }
355
+ | { id: PreferenceId; inherit: true };
356
+
357
+ type PreferenceResult =
358
+ | { status: "applied" }
359
+ | { status: "failed"; failure: ProtocolFailure };
360
+
331
361
  type Snapshot = {
332
362
  status: ConnectionStatus;
333
363
  sessionId: string;
@@ -352,8 +382,8 @@ type CapacityResult =
352
382
  ```ts
353
383
  type Contact = {
354
384
  name?: string;
355
- number?: string;
356
- email?: string;
385
+ number?: Lockable<string>;
386
+ email?: Lockable<string>;
357
387
  attributes?: Attribute[];
358
388
  };
359
389
 
@@ -404,7 +434,9 @@ type CustomCapability = {
404
434
  kind: "button" | "toggle" | "menu-item";
405
435
  label: string;
406
436
  placement: "primary" | "secondary" | "overflow";
437
+ render?: "inline" | "page";
407
438
  };
439
+ prompt?: { fields: CredentialField[] };
408
440
  };
409
441
 
410
442
  type SharedTaskCapabilities = {
@@ -416,20 +448,20 @@ type SharedTaskCapabilities = {
416
448
  type TaskCapabilities<C extends Channel = Channel> =
417
449
  C extends "voice"
418
450
  ? SharedTaskCapabilities & {
419
- decline?: true;
420
- mute?: true;
421
- hold?: true;
422
- agentDisconnect?: true;
423
- callback?: true;
424
- blindTransfer?: true | DestinationDirectory;
425
- consultTransfer?: true | DestinationDirectory;
426
- consultLead?: true;
427
- conference?: true | DestinationDirectory;
428
- recording?: true;
451
+ decline?: Lockable<true>;
452
+ mute?: Lockable<true>;
453
+ hold?: Lockable<true>;
454
+ agentDisconnect?: Lockable<true>;
455
+ callback?: Lockable<true>;
456
+ blindTransfer?: Lockable<true | DestinationDirectory>;
457
+ consultTransfer?: Lockable<true | DestinationDirectory>;
458
+ consultLead?: Lockable<true>;
459
+ conference?: Lockable<true | DestinationDirectory>;
460
+ recording?: Lockable<true>;
429
461
  }
430
462
  : C extends "chat"
431
- ? SharedTaskCapabilities & { reject?: true; hold?: true }
432
- : SharedTaskCapabilities & { reject?: true };
463
+ ? SharedTaskCapabilities & { reject?: Lockable<true>; hold?: Lockable<true> }
464
+ : SharedTaskCapabilities & { reject?: Lockable<true> };
433
465
  ```
434
466
 
435
467
  The channel arms are why `Task<"email">` rejects `hold` at compile time rather than at runtime.
@@ -536,6 +568,20 @@ type TaskAssisting = {
536
568
  since: IsoTimestamp;
537
569
  };
538
570
 
571
+ type Tier = string;
572
+
573
+ type TierDeclaration = {
574
+ id: Tier;
575
+ label: string;
576
+ };
577
+
578
+ type Locked = {
579
+ lockedBy: Tier;
580
+ reason?: string;
581
+ };
582
+
583
+ type Lockable<T> = T | Locked;
584
+
539
585
  type Task<C extends Channel = Channel> = {
540
586
  id: TaskId;
541
587
  title: string;
@@ -645,7 +691,78 @@ type TaskCommandResult =
645
691
  | { status: "failed"; failure: ProtocolFailure };
646
692
  ```
647
693
 
648
- ### Breaks
694
+ ### Who decides what an agent may do
695
+
696
+ An agent desk has two managers, not one. **The queue** — a process, a work type — is owned by a
697
+ process manager and **allows** a set of capabilities: hold, mute, callback, new call, conference,
698
+ whether the number is visible, the actions it offers, the skills it needs. **The people** are
699
+ managed through the organisation's structure — a team, a location, the organisation itself, in
700
+ whatever combination the structure defines for a person — and **decide** per capability within
701
+ what the queue allows: on for everyone, off for everyone, or left to the person. What you do to
702
+ your team is a **policy**; what you do to yourself is a **preference**. A person belongs to one
703
+ team and many queues, so a policy applies across every queue the person works.
704
+
705
+ **The provider resolves; the protocol carries the result and who decided.** The structure's tiers
706
+ are the provider's ladder: each tier states only what it sets, an enforcing policy at a tier above
707
+ the person settles the value for everyone below it, and where nothing enforces the most specific
708
+ tier that says anything wins. The protocol names a tier by the id the manifest declares for it and
709
+ never describes the chain between them: which tiers a person passes through is the structure's to
710
+ know. A typical organisation has four, and they are the defaults — `DEFAULT_TIERS`: `org`, `site`,
711
+ `team`, `person`, each with the label a desk shows. A structure that differs states its whole
712
+ ladder in `Manifest.orgTiers`, `person` included: what the list carries is in force, and what it
713
+ leaves out does not exist — a structure with no site tier declares `org`, `team`, `person`, and
714
+ `site` is refused on its wire. A declared tier is one the provider's own store actually resolves
715
+ at: a label with no policy behind it decides nothing. A manifest that declares none has exactly
716
+ the four. `lockedBy` is any tier in force except `person`, who never locks their own value;
717
+ `setBy` is any tier in force, or `provisioning`, the protocol's word for "no tier has said
718
+ anything and the provider's own configuration supplied the value" — the provider speaking, never
719
+ Omni's provisioning file, which does not reach the wire. A host renders "who decided" from the
720
+ declared labels and needs no others, and validates every republished `authenticated` state
721
+ against them, not only the sign-in. What the wire carries is the resolution:
722
+
723
+ - **`lockedBy`** — a tier above the person made this value theirs to keep. A person never locks
724
+ their own value, and the queue is not a tier: what the queue does not allow at all is absent.
725
+ - **`setBy`** — who stated the value as it stands: a tier, the `person` themself, or
726
+ `provisioning` where no tier has said anything. Provenance, not a lock: a value that came from a
727
+ broad tier as a default is still the person's to change.
728
+
729
+ **On a task, a control the queue could allow may stand locked in its place.** `Task.capabilities`
730
+ is the effective set. What the queue does not allow is absent and nothing is shown. What the queue
731
+ allows and a tier above the person locked is present as `{ lockedBy, reason? }` where the control's
732
+ value would be — `mute: { lockedBy: "team", reason: "Nobody on this team mutes" }` — and Omni
733
+ renders that control disabled, saying who decided, so an agent who cannot press Mute knows whether
734
+ to ask their lead or their site. `lockedBy` is the discriminant: a value that carries it is the
735
+ lock, so nothing that can be locked — a directory, a number — may carry that key itself. A
736
+ contact's number and email are the same, since each identifies a person: where the queue says
737
+ the agent may not see it, the provider sends `{ lockedBy }` in its place — the last digits or
738
+ nothing — and a CRM link carries a token, never the value with a flag the desk is asked to honour.
739
+ A name is not locked. What the queue provides rather than permits — browsers, dispositions, custom
740
+ controls — is content, and is never locked.
741
+
742
+ **A lead sets the team's policy from their roster.** A login that declares
743
+ `capabilities.team.policyControl` may `executeTeamPolicy({ type: "set", capability, setting })`
744
+ with `on`, `off`, or `agent`, for any task control, `dial`, or a skill — and only `hold`, `mute`
745
+ and skills may be `agent`; callback and new call are the team's, on or off, within what the queue
746
+ allows. The roster carries `policies` for such a login: every policy as it stands, who set it, and
747
+ `lockedBy` where a tier above the team made it theirs to keep, which the lead sees and cannot
748
+ change — `executeTeamPolicy` on it answers `failed` with `omni.capability-not-enabled`.
749
+
750
+ **What the team left to the person is the person's, and the provider keeps it.** The login's
751
+ `capabilities.preferences` lists every preference the person may hold — `hold`, `mute`, a skill —
752
+ with where it stands and who set it: `setBy: "team"` while they inherit the team's default,
753
+ `"person"` once they have set their own, `"provisioning"` where no tier has said anything. Nothing
754
+ is hidden for want of a row, and a preference a tier above has since locked is listed with
755
+ `lockedBy`. `setPreference` is the person's act — `{ id, enabled }` to set their own, `{ id,
756
+ inherit: true }` to give it up and inherit again — answered `applied` and republished as a new
757
+ `authenticated` state when something changed, a state and not a flicker, as every republish of
758
+ `authenticated` is; and it is durable: the person's across sessions. A lead may also set a
759
+ person's preference from their own screen, which arrives the same way. A preference is keyed by
760
+ the capability's own name because it is the same capability at another tier: effective in
761
+ `Task.capabilities`, set for the team in `policies`, left to the person in `preferences`. The
762
+ command `mute` acts on one call; the preference `mute` says whether the person wants the control
763
+ at all, and a host renders it in its settings, never as the button on a call.
764
+
765
+ ## Breaks
649
766
 
650
767
  ```ts
651
768
  type BreakApproval =
@@ -723,6 +840,28 @@ type LeadRequest = {
723
840
  type TeamRoster = {
724
841
  members: TeamMember[];
725
842
  requests?: LeadRequest[];
843
+ policies?: TeamPolicies;
844
+ };
845
+
846
+ type PolicyKey =
847
+ | Exclude<keyof TaskCapabilities<"voice">, keyof SharedTaskCapabilities>
848
+ | Exclude<keyof TaskCapabilities<"chat">, keyof SharedTaskCapabilities>
849
+ | Exclude<keyof TaskCapabilities<"email">, keyof SharedTaskCapabilities>
850
+ | "dial"
851
+ | `skill:${string}`;
852
+
853
+ type TeamPolicySetting = "on" | "off" | "agent";
854
+
855
+ type TeamPolicy = Resolved & {
856
+ setting: TeamPolicySetting;
857
+ };
858
+
859
+ type TeamPolicies = Partial<Record<PolicyKey, TeamPolicy>>;
860
+
861
+ type TeamPolicyCommand = { type: "set"; capability: PolicyKey; setting: TeamPolicySetting };
862
+
863
+ type TeamPolicyCommandRequest = {
864
+ command: TeamPolicyCommand;
726
865
  };
727
866
 
728
867
  type TeamConsultCommand =
@@ -833,6 +972,8 @@ type Connection<C extends Channel = Channel> = {
833
972
  executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
834
973
  executeTeamConsult?(request: TeamConsultCommandRequest): Promise<TeamCommandResult>;
835
974
  openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
975
+ setPreference?(request: SetPreferenceRequest): Promise<PreferenceResult>;
976
+ executeTeamPolicy?(request: TeamPolicyCommandRequest): Promise<TeamCommandResult>;
836
977
  };
837
978
 
838
979
  type Adapter<C extends Channel = Channel> = {
@@ -850,6 +991,13 @@ const ALLOWED_BROWSER_URL_SCHEMES = ["http:", "https:"] as const;
850
991
  const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"] as const;
851
992
  type IdleCapability = (typeof IDLE_CAPABILITIES)[number];
852
993
 
994
+ const DEFAULT_TIERS = [
995
+ { id: "org", label: "Your organisation" },
996
+ { id: "site", label: "Your site" },
997
+ { id: "team", label: "Your team" },
998
+ { id: "person", label: "You" },
999
+ ] as const satisfies readonly TierDeclaration[];
1000
+
853
1001
  const IDLE_CAPABILITY_UI = {
854
1002
  dial: "Dialpad",
855
1003
  personalBrowser: "Browser",
@@ -1205,6 +1353,7 @@ compile time.
1205
1353
  | `idleCapabilities` | Declares actions Omni may offer while the agent has no active task, such as voice dialing. Task controls do not belong here. |
1206
1354
  | `phaseLabels` | Optional static adapter-defined display names for canonical `TaskPhase` values. They cannot vary at runtime. |
1207
1355
  | `taskTypePresentation` | Optional static adapter-defined presentation keyed by exact `taskType`. It names the item and its optional agent-facing reference. |
1356
+ | `orgTiers` | The organisation's whole ladder as the provider calls it, each tier with the label a desk shows for "who decided". Stated outright, `person` included: what it leaves out does not exist. Omitted for the typical four, `DEFAULT_TIERS`. See **Who decides what an agent may do**. |
1208
1357
 
1209
1358
  ### Authentication methods
1210
1359
 
@@ -1512,6 +1661,8 @@ them from what arrives later.
1512
1661
  | `team` | This login leads a team. The provider publishes a `TeamRoster` to it on every snapshot — `[]` when nobody is in it — and to nobody else. |
1513
1662
  | `team.breakControl` | This lead may act on their team's breaks through `executeTeamBreak` — place, release, decide, set policy — as far as the provider supports; a command it lacks answers `omni.capability-not-enabled`. Omni asks for a decision only against a member whose `break` is `awaiting-decision`, so a provider that grants on request is never asked to decide. Requires `executeTeamBreak`. |
1514
1663
  | `team.consultControl` | This lead may join a member's call on request. Requires `executeTeamConsult`. |
1664
+ | `team.policyControl` | This lead sets the team's policy per capability — on, off, or the agent's — within what the queue allows. Requires `executeTeamPolicy`; the roster carries `policies`. |
1665
+ | `preferences` | What the team left to this person, with where each stands and who set it. Omitted when nothing was. Requires `setPreference`. See **Who decides what an agent may do**. |
1515
1666
 
1516
1667
  A session action is available only when both the capability and Omni provisioning permit it.
1517
1668
 
@@ -1702,6 +1853,8 @@ surface in one place, and what obliges an adapter to implement each one.
1702
1853
  | `endBreak()` | The login declares `capabilities.breaks`. |
1703
1854
  | `executeTeamBreak(command)` | The login declares `capabilities.team.breakControl`. |
1704
1855
  | `executeTeamConsult(command)` | The login declares `capabilities.team.consultControl`. |
1856
+ | `setPreference(request)` | The login declares `capabilities.preferences`: the person's choice has to have somewhere to go. |
1857
+ | `executeTeamPolicy(command)` | The login declares `capabilities.team.policyControl`. |
1705
1858
  | `openMedia(request)` | The manifest channel is `voice`. Every voice task's audio lands in Omni, so there is no voice adapter that does not implement it. |
1706
1859
 
1707
1860
  **The four break methods stand or fall together.** Declaring `capabilities.breaks` at login and then
@@ -2379,8 +2532,15 @@ capabilities: {
2379
2532
  ```
2380
2533
 
2381
2534
  Custom capability IDs must be non-empty and unique within the task. `ui.kind` is `button`, `toggle`,
2382
- or `menu-item`; `ui.placement` is `primary`, `secondary`, or `overflow`. Omni renders the control
2383
- and invokes it with the shared custom task command:
2535
+ or `menu-item`; `ui.placement` is `primary`, `secondary`, or `overflow`. `ui.render` says where the
2536
+ control's work appears: `inline`, in the workspace beside the task, or `page`, as a page of its own
2537
+ — a tab in the same work area as the task's browsers, beside them, and alone on a task that has
2538
+ none; inline when absent. `prompt.fields` are what the agent supplies before the action runs — a
2539
+ destination number, a reference — as `CredentialField`s Omni renders as a form; the values travel
2540
+ on the custom command under the fields' names, as strings, and Omni sends the command only once
2541
+ every `required` field has a value. The provider validates what arrives as it validates any
2542
+ command; a form is a declaration, not a contract for what the agent typed. Omni renders the
2543
+ control and invokes it with the shared custom task command:
2384
2544
 
2385
2545
  ```ts
2386
2546
  {
@@ -2788,6 +2948,7 @@ A lead who also takes calls sees their team on the idle dashboard. `Snapshot.tea
2788
2948
  | --- | --- |
2789
2949
  | `members` | Every member of this lead's team, whatever their state. `[]` says the lead has a team with nobody in it; omitting the roster says something else entirely — see **The login is the permission** below. |
2790
2950
  | `requests` | The members currently asking this lead to join a call, each with the task and the note. Required when the login declares `team.consultControl`, `[]` when nobody is asking; omitted when it does not. See **Consulting a lead**. |
2951
+ | `policies` | The team's policy per capability as it stands — the setting, who set it, and `lockedBy` where a tier above the team made it theirs to keep. Required when the login declares `team.policyControl`; omitted when it does not. See **Who decides what an agent may do**. |
2791
2952
 
2792
2953
  | `TeamMember` field | Contract |
2793
2954
  | --- | --- |
@@ -2998,6 +3159,7 @@ what failed, and reports. It never decides for the adapter what a missing microp
2998
3159
  | `online` | Whether the host has a network interface up. Not a claim that anything is reachable — the adapter knows whether it can reach its own platform far better than the host does — so `false` is a reason not to go ready and `true` is not a reason to. |
2999
3160
  | `audio` | Present on a voice connection, absent where there is no audio. |
3000
3161
  | `audio.input` | `ready` with `localAudio` — the microphone as captured, the same stream `openMedia` receives — and `flowing`, false while the hardware or OS says no audio moves through it (a headset's own mute switch, which Omni's Mute control never touches). `unavailable` with `reason`, since each wants a different fix from the agent: `no-device`; `denied`; `not-asked`, which a host that asks at connect never publishes; `in-use`, a device present and permitted that another application holds — on an agent desktop the commonest of all; `lost`, a capture that ended. A host decides the reason from the devices before the error name: a browser can report a permission error on a machine with no microphone at all, and "grant permission" is the wrong instruction for an agent who needs to plug one in. `failure` carries the words Omni showed them. |
3162
+ | `browsers.urlVisibility` | What Omni's own chrome shows of a task browser's URL: `hidden`, `domain`, or `full`. A statement about the chrome, not about what the page renders or a screenshot captures; task browsers only — the personal browser is the agent's own and nothing a provider sends appears in it. A provider reads it before deciding what URL it is willing to send: one that carries confidential data goes out under `hidden` and not under `full`. |
3001
3163
  | `audio.output` | `ready`, or `unavailable` with `reason` — `no-device`, or `lost` for one removed — and `failure`: an agent who cannot hear is as unable to take a call as one who cannot speak. |
3002
3164
 
3003
3165
  Omni republishes the report whenever it changes — a permission granted late, a headset unplugged,
@@ -3410,7 +3572,7 @@ same exported checks are used by Omni and adapter tests so their interpretations
3410
3572
  | `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
3411
3573
  | `validateHostReport(report)` | The host's own report as published to an adapter: `online`, and where there is audio, an input that is `ready` with the microphone and `flowing`, or `unavailable` with a reason and the failure that says why, and an output that is `ready` or `unavailable` with its failure. The harness validates whatever host a test hands the adapter; `stillHost(report)` builds one that never changes. |
3412
3574
  | `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. |
3413
- | `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. |
3575
+ | `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. |
3414
3576
 
3415
3577
  Each returns `ProtocolViolation[]` rather than throwing, so a caller can report every problem at
3416
3578
  once. A violation carries a stable `rule` id such as `task.browser.url.scheme`, the `path` it was
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.19",
3
+ "version": "0.1.21",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",