@xema/omni-protocol 0.1.10 → 0.1.12
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/README.md +17 -11
- package/dist/index.d.ts +32 -18
- package/dist/testing.d.ts +57 -5
- package/dist/testing.js +304 -32
- package/dist/validation.d.ts +8 -3
- package/dist/validation.js +62 -20
- package/guide.md +139 -65
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -40,22 +40,28 @@ assertNoViolations(violations);
|
|
|
40
40
|
A violation carries a stable `rule` id such as `task.browser.url.scheme`, the `path` it was found
|
|
41
41
|
at such as `snapshot.tasks[0].browsers[1].url`, and a `message`.
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
`team.member.self
|
|
48
|
-
|
|
43
|
+
Some rules need more than the object in hand. A roster never carries the agent it is published to,
|
|
44
|
+
a lead's snapshot always carries one and nobody else's ever does — and a validator cannot know who
|
|
45
|
+
is reading, or what their login declares, from the snapshot alone. `validateTeamRoster`,
|
|
46
|
+
`validateSnapshot`, and `validateEventEnvelope` each take an optional final `{ self, capabilities }`
|
|
47
|
+
from the `authenticated` state; given them, they report `team.member.self`, `team.request.self`,
|
|
48
|
+
`team.required`, `team.unentitled`, `team.requests.capability`, and `team.requests.required`.
|
|
49
|
+
Without them those rules are not checked. `exerciseAdapter` always passes both.
|
|
49
50
|
|
|
50
51
|
```ts
|
|
51
|
-
|
|
52
|
+
const { identity, capabilities } = authenticated;
|
|
53
|
+
validateSnapshot(snapshot, manifest, "snapshot", { self: identity.id, capabilities });
|
|
52
54
|
```
|
|
53
55
|
|
|
54
56
|
## Conformance
|
|
55
57
|
|
|
56
58
|
`exerciseAdapter` validates the manifest, opens an authenticated session, connects, checks
|
|
57
|
-
required capability methods, subscribes, validates the snapshot
|
|
58
|
-
|
|
59
|
+
required capability methods, subscribes, validates the snapshot, every delivered event, and every
|
|
60
|
+
authentication state published during the run — against the latest login — states a capacity,
|
|
61
|
+
then unsubscribes and disconnects. `result.notExercised` names what the run never reached — each
|
|
62
|
+
optional part of a task, of the break state and roster, each contribution, each event type — so a
|
|
63
|
+
clean result is read for what it covers and not for the whole contract; `assertReached(result,
|
|
64
|
+
subjects)` is the paired assertion.
|
|
59
65
|
|
|
60
66
|
```ts
|
|
61
67
|
const result = await exerciseAdapter(adapter, context, { collectOnly: true });
|
|
@@ -63,8 +69,8 @@ expect(result.violations).toEqual([]);
|
|
|
63
69
|
expect(result.disconnectWasClean).toBe(true);
|
|
64
70
|
```
|
|
65
71
|
|
|
66
|
-
Run the contract scenarios beside it — authentication restore and expiry,
|
|
67
|
-
assignments, break denial and retry, wrap timeout, browser isolation.
|
|
72
|
+
Run the contract scenarios beside it — authentication restore and expiry, capability withdrawal,
|
|
73
|
+
reconnect with missed assignments, break denial and retry, wrap timeout, browser isolation.
|
|
68
74
|
|
|
69
75
|
> **Assert both directions.** Every helper rejects a violating input as well as accepting a
|
|
70
76
|
> conforming one. A suite that only asserts "this conforming case does not throw" passes unchanged
|
package/dist/index.d.ts
CHANGED
|
@@ -135,9 +135,28 @@ export interface AuthenticationFailure {
|
|
|
135
135
|
/** Names a declared credentials field when the failure belongs to one. */
|
|
136
136
|
field?: string;
|
|
137
137
|
}
|
|
138
|
+
/** What a lead may do with their team. Declared by presence. */
|
|
139
|
+
export interface TeamCapabilities {
|
|
140
|
+
/** This lead decides their team's breaks. Requires `executeTeamBreak`. */
|
|
141
|
+
breakControl?: true;
|
|
142
|
+
/** This lead may join a member's call on request. Requires `executeTeamConsult`. */
|
|
143
|
+
consultControl?: true;
|
|
144
|
+
}
|
|
138
145
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
146
|
+
* What this login may do, beyond any one task. It travels with the identity because it is part
|
|
147
|
+
* of who the agent is on this provider: the provider knows the roles and says so at sign-in.
|
|
148
|
+
* Current as of the latest `authenticated` state, never fixed for the login.
|
|
149
|
+
*/
|
|
150
|
+
export interface SessionCapabilities {
|
|
151
|
+
/** This login may request a break. Requires the four break methods. */
|
|
152
|
+
breaks?: true;
|
|
153
|
+
/** This login leads a team: a `TeamRoster` is published to it on every snapshot, `[]` included. */
|
|
154
|
+
team?: TeamCapabilities;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Only `authenticated` and `refreshing` know who the agent is and what they may do, and only
|
|
158
|
+
* `authenticated` has something to expire. A state carrying more than it knows is a state Omni
|
|
159
|
+
* would render as fact.
|
|
141
160
|
*/
|
|
142
161
|
export type AuthenticationState = {
|
|
143
162
|
status: "signed-out";
|
|
@@ -146,10 +165,12 @@ export type AuthenticationState = {
|
|
|
146
165
|
} | {
|
|
147
166
|
status: "authenticated";
|
|
148
167
|
identity: User;
|
|
168
|
+
capabilities: SessionCapabilities;
|
|
149
169
|
expiresAt?: IsoTimestamp;
|
|
150
170
|
} | {
|
|
151
171
|
status: "refreshing";
|
|
152
172
|
identity: User;
|
|
173
|
+
capabilities: SessionCapabilities;
|
|
153
174
|
} | {
|
|
154
175
|
status: "expired";
|
|
155
176
|
identity?: User;
|
|
@@ -199,6 +220,7 @@ export type CompleteAuthenticationRequest = {
|
|
|
199
220
|
export type CompleteAuthenticationResult = {
|
|
200
221
|
status: "authenticated";
|
|
201
222
|
identity: User;
|
|
223
|
+
capabilities: SessionCapabilities;
|
|
202
224
|
expiresAt?: IsoTimestamp;
|
|
203
225
|
} | {
|
|
204
226
|
status: "rejected";
|
|
@@ -705,10 +727,6 @@ export interface TeamMember {
|
|
|
705
727
|
since?: IsoTimestamp;
|
|
706
728
|
break?: BreakApproval;
|
|
707
729
|
}
|
|
708
|
-
/**
|
|
709
|
-
* Published only to an agent entitled to one. Its presence is the permission -- nothing else
|
|
710
|
-
* makes somebody a lead, and there is no separate flag to fall out of step with the data.
|
|
711
|
-
*/
|
|
712
730
|
/** A member asking this lead to join their call. */
|
|
713
731
|
export interface LeadRequest {
|
|
714
732
|
id: string;
|
|
@@ -717,12 +735,13 @@ export interface LeadRequest {
|
|
|
717
735
|
note?: string;
|
|
718
736
|
since: IsoTimestamp;
|
|
719
737
|
}
|
|
738
|
+
/**
|
|
739
|
+
* The login is the permission: published to a login that declares `capabilities.team`, on every
|
|
740
|
+
* snapshot, and to nobody else. What the lead may do with it is on the login too, not here.
|
|
741
|
+
*/
|
|
720
742
|
export interface TeamRoster {
|
|
721
743
|
members: TeamMember[];
|
|
722
|
-
|
|
723
|
-
/** Present when this lead may join a member's call on request. */
|
|
724
|
-
consultControl?: true;
|
|
725
|
-
/** Omitted when the lead may not be asked; `[]` when nobody is asking. */
|
|
744
|
+
/** Omitted when the login lacks `team.consultControl`; `[]` when nobody is asking. */
|
|
726
745
|
requests?: LeadRequest[];
|
|
727
746
|
}
|
|
728
747
|
export type TeamConsultCommand = {
|
|
@@ -777,15 +796,10 @@ export type OpenMediaResult = {
|
|
|
777
796
|
status: "unavailable";
|
|
778
797
|
failure: ProtocolFailure;
|
|
779
798
|
};
|
|
780
|
-
export interface SessionCapabilities {
|
|
781
|
-
breaks?: true;
|
|
782
|
-
teamBreakControl?: true;
|
|
783
|
-
}
|
|
784
799
|
/** The provider's complete state at one moment. It replaces what Omni holds; never a patch. */
|
|
785
800
|
export interface Snapshot<C extends Channel = Channel> {
|
|
786
801
|
status: ConnectionStatus;
|
|
787
802
|
sessionId: string;
|
|
788
|
-
sessionCapabilities: SessionCapabilities;
|
|
789
803
|
break: BreakState;
|
|
790
804
|
/** Every task currently owned by this agent for this provider. */
|
|
791
805
|
tasks: Task<C>[];
|
|
@@ -888,7 +902,7 @@ export interface Connection<C extends Channel = Channel> {
|
|
|
888
902
|
/** Required when the manifest declares `idleCapabilities.dial`. */
|
|
889
903
|
dial?(request: DialRequest): Promise<DialResult>;
|
|
890
904
|
/**
|
|
891
|
-
* The four break methods stand or fall together. Declaring `
|
|
905
|
+
* The four break methods stand or fall together. Declaring `capabilities.breaks` at login and
|
|
892
906
|
* implementing `requestBreak` without `commitBreak` leaves an agent granted a break that can
|
|
893
907
|
* never start, and the two-phase coordination has no way to report it.
|
|
894
908
|
*/
|
|
@@ -896,9 +910,9 @@ export interface Connection<C extends Channel = Channel> {
|
|
|
896
910
|
commitBreak?(): Promise<BreakCommitResult>;
|
|
897
911
|
cancelBreak?(): Promise<BreakCancelResult>;
|
|
898
912
|
endBreak?(): Promise<BreakEndResult>;
|
|
899
|
-
/** Required when the
|
|
913
|
+
/** Required when the login declares `capabilities.team.breakControl`. */
|
|
900
914
|
executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
|
|
901
|
-
/** Required when the
|
|
915
|
+
/** Required when the login declares `capabilities.team.consultControl`. */
|
|
902
916
|
executeTeamConsult?(request: TeamConsultCommandRequest): Promise<TeamCommandResult>;
|
|
903
917
|
/** Required of every voice adapter: all voice audio lands in Omni. */
|
|
904
918
|
openMedia?(request: OpenMediaRequest): Promise<OpenMediaResult>;
|
package/dist/testing.d.ts
CHANGED
|
@@ -1,11 +1,32 @@
|
|
|
1
|
-
import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext } from "./index.js";
|
|
1
|
+
import { type Adapter, type AuthenticationState, type ProviderEventEnvelope, type Snapshot, type TaskCompletion, type BreakApproval, type BrowserSessionKeyInput, type Channel, type ConnectContext, type Manifest, type ProviderEvent } from "./index.js";
|
|
2
2
|
import { type ProtocolViolation } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation } from "./validation.js";
|
|
4
|
+
/**
|
|
5
|
+
* A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
|
|
6
|
+
* a fixture without it exercises none of its rules and passes clean. One subject per family of
|
|
7
|
+
* rules -- each optional part of a task, each optional part of the break state and roster, each
|
|
8
|
+
* declared contribution, and each event type.
|
|
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"];
|
|
11
|
+
export type ContractSubject = (typeof STATE_SUBJECTS)[number] | `event.${ProviderEvent["type"]}`;
|
|
4
12
|
export interface AdapterContractResult {
|
|
5
13
|
events: ProviderEventEnvelope[];
|
|
14
|
+
/** The state the session was restored with at sign-in. */
|
|
6
15
|
authenticationState: AuthenticationState;
|
|
7
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* The latest login the session published during the run. Equal to `authenticationState` unless
|
|
18
|
+
* the adapter republished `authenticated` -- capabilities are current, not fixed.
|
|
19
|
+
*/
|
|
20
|
+
login: AuthenticationState;
|
|
21
|
+
/** True only when every unsubscribe, `disconnect()`, and `close()` settled without throwing. */
|
|
8
22
|
disconnectWasClean: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* What the run never reached, and so what a clean `violations` says nothing about. Nothing here
|
|
25
|
+
* is a violation -- an adapter with no team has nothing to exercise -- but a fixture with no
|
|
26
|
+
* tasks exercises no task rule, and an adapter's own test asserts that the subjects it meant to
|
|
27
|
+
* reach are absent from this list.
|
|
28
|
+
*/
|
|
29
|
+
notExercised: readonly ContractSubject[];
|
|
9
30
|
/** Every violation observed. Non-empty only when `collectOnly` suppressed the throw. */
|
|
10
31
|
violations: readonly ProtocolViolation[];
|
|
11
32
|
}
|
|
@@ -16,8 +37,9 @@ export interface ExerciseAdapterOptions {
|
|
|
16
37
|
/**
|
|
17
38
|
* Adapter conformance exercise: validates the manifest, opens an authenticated session,
|
|
18
39
|
* connects, checks that every method the declarations require is implemented, subscribes,
|
|
19
|
-
* validates the snapshot
|
|
20
|
-
*
|
|
40
|
+
* validates the snapshot, every delivered event, and every authentication state the session
|
|
41
|
+
* publishes during the run -- against the latest login, since capabilities are current, not
|
|
42
|
+
* fixed -- states a capacity, then unsubscribes and disconnects.
|
|
21
43
|
*
|
|
22
44
|
* Violations are collected rather than thrown from inside the adapter's own
|
|
23
45
|
* dispatch path. Throwing from a subscribe listener unwinds through the provider
|
|
@@ -25,8 +47,38 @@ export interface ExerciseAdapterOptions {
|
|
|
25
47
|
* asynchronous one — which would let a non-conforming async adapter pass.
|
|
26
48
|
*/
|
|
27
49
|
export declare function exerciseAdapter<C extends Channel>(adapter: Adapter<C>, context: ConnectContext, options?: ExerciseAdapterOptions): Promise<AdapterContractResult>;
|
|
28
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Validates restored authentication followed by a refresh failure or expiry. Every state is
|
|
52
|
+
* validated, and a `refreshing` state must carry over the login it refreshes.
|
|
53
|
+
*/
|
|
29
54
|
export declare function assertAuthenticationRestoreAndExpiry(states: readonly AuthenticationState[]): void;
|
|
55
|
+
/**
|
|
56
|
+
* Capabilities are current, not fixed. A provider that withdraws one republishes `authenticated`
|
|
57
|
+
* with the new set, and the next snapshot agrees with it. `states` is what the authentication
|
|
58
|
+
* session published, first to last: beginning and ending `authenticated` for the same identity,
|
|
59
|
+
* passing only through usable states (`expired` or `signed-out` ends the login instead), with at
|
|
60
|
+
* least one capability gone by the end. `snapshot` is the first snapshot published after the last
|
|
61
|
+
* state, validated against that login -- so a roster still published to a login that no longer
|
|
62
|
+
* leads, or requests to one that may no longer join, is the failure.
|
|
63
|
+
*/
|
|
64
|
+
export declare function assertCapabilityWithdrawal(states: readonly AuthenticationState[], snapshot: Snapshot, manifest: Manifest): void;
|
|
65
|
+
/**
|
|
66
|
+
* A command that arrives after its capability was withdrawn is answered `failed` with
|
|
67
|
+
* `omni.capability-not-enabled`: the provider names it, so Omni never has to infer from a
|
|
68
|
+
* capability change it may not have rendered yet that "no longer a lead" is the message.
|
|
69
|
+
* Takes any command result -- a team command, a break method -- after such a withdrawal.
|
|
70
|
+
*/
|
|
71
|
+
export declare function assertCommandRefusedAfterWithdrawal(result: {
|
|
72
|
+
status: string;
|
|
73
|
+
failure?: {
|
|
74
|
+
code: string;
|
|
75
|
+
};
|
|
76
|
+
}): void;
|
|
77
|
+
/**
|
|
78
|
+
* Throws unless the run reached every subject named: the paired assertion beside a clean result,
|
|
79
|
+
* so a fixture that never produced a roster cannot pass a test that meant to check one.
|
|
80
|
+
*/
|
|
81
|
+
export declare function assertReached(result: AdapterContractResult, subjects: readonly ContractSubject[]): void;
|
|
30
82
|
/** Validates duplicate delivery and returns the event sequence Omni applies once per ID. */
|
|
31
83
|
export declare function assertDuplicateEventDelivery<C extends Channel>(envelopes: readonly ProviderEventEnvelope<C>[]): ProviderEventEnvelope<C>[];
|
|
32
84
|
/** Validates an authoritative reconnect snapshot containing assignments missed while offline. */
|
package/dist/testing.js
CHANGED
|
@@ -1,11 +1,134 @@
|
|
|
1
1
|
import { browserSessionKey, } from "./index.js";
|
|
2
2
|
import { assertNoViolations, validateAuthenticationState, validateEventEnvelope, validateManifest, validateSnapshot, } from "./validation.js";
|
|
3
3
|
export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
4
|
+
/**
|
|
5
|
+
* A part of the contract a run may never reach: state nothing obliges an adapter to publish, so
|
|
6
|
+
* a fixture without it exercises none of its rules and passes clean. One subject per family of
|
|
7
|
+
* rules -- each optional part of a task, each optional part of the break state and roster, each
|
|
8
|
+
* declared contribution, and each event type.
|
|
9
|
+
*/
|
|
10
|
+
const STATE_SUBJECTS = [
|
|
11
|
+
"tasks",
|
|
12
|
+
"task.browsers",
|
|
13
|
+
"task.attributes",
|
|
14
|
+
"task.handlingHistory",
|
|
15
|
+
"task.consultation",
|
|
16
|
+
"task.lead",
|
|
17
|
+
"task.assisting",
|
|
18
|
+
"task.dispositions",
|
|
19
|
+
"task.destinations",
|
|
20
|
+
"task.custom",
|
|
21
|
+
"break.reasons",
|
|
22
|
+
"break.imposed",
|
|
23
|
+
"team.members",
|
|
24
|
+
"team.requests",
|
|
25
|
+
"contacts",
|
|
26
|
+
"scheduledActivities",
|
|
27
|
+
];
|
|
28
|
+
// Pinned to the event union the way validation pins its closed sets: a type added to
|
|
29
|
+
// `ProviderEvent` without a row here, or a row it lacks, is a compile error.
|
|
30
|
+
const EVENT_TYPES = {
|
|
31
|
+
snapshot: true, "provider-status": true, "break-state": true, "task-offered": true, "task-updated": true,
|
|
32
|
+
"task-media-ended": true, "task-ended": true, announcement: true, "provider-summary": true,
|
|
33
|
+
"team-updated": true, "contacts-updated": true, "calendar-updated": true,
|
|
34
|
+
};
|
|
35
|
+
const CONTRACT_SUBJECTS = [
|
|
36
|
+
...STATE_SUBJECTS,
|
|
37
|
+
...Object.keys(EVENT_TYPES).map(type => `event.${type}`),
|
|
38
|
+
];
|
|
39
|
+
// What the run observed. The input is untrusted and has already been reported on, so nothing
|
|
40
|
+
// here assumes its shape; a subject is reached only by something the element rules would see.
|
|
41
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
42
|
+
const some = (value) => Array.isArray(value) && value.length > 0;
|
|
43
|
+
function observeTask(value, seen) {
|
|
44
|
+
if (!isRecord(value))
|
|
45
|
+
return;
|
|
46
|
+
seen.add("tasks");
|
|
47
|
+
if (some(value.browsers))
|
|
48
|
+
seen.add("task.browsers");
|
|
49
|
+
if (some(value.attributes))
|
|
50
|
+
seen.add("task.attributes");
|
|
51
|
+
if (some(value.handlingHistory))
|
|
52
|
+
seen.add("task.handlingHistory");
|
|
53
|
+
if (value.consultation !== undefined)
|
|
54
|
+
seen.add("task.consultation");
|
|
55
|
+
if (value.lead !== undefined)
|
|
56
|
+
seen.add("task.lead");
|
|
57
|
+
if (value.assisting !== undefined)
|
|
58
|
+
seen.add("task.assisting");
|
|
59
|
+
const capabilities = isRecord(value.capabilities) ? value.capabilities : {};
|
|
60
|
+
if (isRecord(capabilities.dispositions))
|
|
61
|
+
seen.add("task.dispositions");
|
|
62
|
+
if (isRecord(capabilities.blindTransfer) && some(capabilities.blindTransfer.destinations))
|
|
63
|
+
seen.add("task.destinations");
|
|
64
|
+
if (some(capabilities.custom))
|
|
65
|
+
seen.add("task.custom");
|
|
66
|
+
}
|
|
67
|
+
function observeBreak(value, seen) {
|
|
68
|
+
if (!isRecord(value))
|
|
69
|
+
return;
|
|
70
|
+
if (some(value.reasons))
|
|
71
|
+
seen.add("break.reasons");
|
|
72
|
+
if (value.imposed !== undefined)
|
|
73
|
+
seen.add("break.imposed");
|
|
74
|
+
}
|
|
75
|
+
function observeTeam(value, seen) {
|
|
76
|
+
if (!isRecord(value))
|
|
77
|
+
return;
|
|
78
|
+
if (some(value.members))
|
|
79
|
+
seen.add("team.members");
|
|
80
|
+
if (some(value.requests))
|
|
81
|
+
seen.add("team.requests");
|
|
82
|
+
}
|
|
83
|
+
function observeSnapshot(value, seen) {
|
|
84
|
+
if (!isRecord(value))
|
|
85
|
+
return;
|
|
86
|
+
if (Array.isArray(value.tasks))
|
|
87
|
+
value.tasks.forEach(task => observeTask(task, seen));
|
|
88
|
+
observeBreak(value.break, seen);
|
|
89
|
+
observeTeam(value.team, seen);
|
|
90
|
+
if (some(value.contacts))
|
|
91
|
+
seen.add("contacts");
|
|
92
|
+
if (some(value.scheduledActivities))
|
|
93
|
+
seen.add("scheduledActivities");
|
|
94
|
+
}
|
|
95
|
+
function observeEvent(envelope, seen) {
|
|
96
|
+
const event = isRecord(envelope) ? envelope.event : undefined;
|
|
97
|
+
if (!isRecord(event))
|
|
98
|
+
return;
|
|
99
|
+
if (typeof event.type === "string" && event.type in EVENT_TYPES)
|
|
100
|
+
seen.add(`event.${event.type}`);
|
|
101
|
+
switch (event.type) {
|
|
102
|
+
case "snapshot":
|
|
103
|
+
observeSnapshot(event.snapshot, seen);
|
|
104
|
+
break;
|
|
105
|
+
case "break-state":
|
|
106
|
+
observeBreak(event.break, seen);
|
|
107
|
+
break;
|
|
108
|
+
case "task-offered":
|
|
109
|
+
case "task-updated":
|
|
110
|
+
observeTask(event.task, seen);
|
|
111
|
+
break;
|
|
112
|
+
case "team-updated":
|
|
113
|
+
observeTeam(event.team, seen);
|
|
114
|
+
break;
|
|
115
|
+
case "contacts-updated":
|
|
116
|
+
if (some(event.contacts))
|
|
117
|
+
seen.add("contacts");
|
|
118
|
+
break;
|
|
119
|
+
case "calendar-updated":
|
|
120
|
+
if (some(event.scheduledActivities))
|
|
121
|
+
seen.add("scheduledActivities");
|
|
122
|
+
break;
|
|
123
|
+
default: break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
4
126
|
/**
|
|
5
127
|
* Adapter conformance exercise: validates the manifest, opens an authenticated session,
|
|
6
128
|
* connects, checks that every method the declarations require is implemented, subscribes,
|
|
7
|
-
* validates the snapshot
|
|
8
|
-
*
|
|
129
|
+
* validates the snapshot, every delivered event, and every authentication state the session
|
|
130
|
+
* publishes during the run -- against the latest login, since capabilities are current, not
|
|
131
|
+
* fixed -- states a capacity, then unsubscribes and disconnects.
|
|
9
132
|
*
|
|
10
133
|
* Violations are collected rather than thrown from inside the adapter's own
|
|
11
134
|
* dispatch path. Throwing from a subscribe listener unwinds through the provider
|
|
@@ -15,6 +138,7 @@ export { ProtocolConformanceError, assertNoViolations } from "./validation.js";
|
|
|
15
138
|
export async function exerciseAdapter(adapter, context, options = {}) {
|
|
16
139
|
const violations = [...validateManifest(adapter.manifest)];
|
|
17
140
|
const events = [];
|
|
141
|
+
const seen = new Set();
|
|
18
142
|
const storedSecrets = new Map();
|
|
19
143
|
const authentication = await adapter.createAuthenticationSession({
|
|
20
144
|
...context,
|
|
@@ -26,7 +150,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
26
150
|
});
|
|
27
151
|
let connection;
|
|
28
152
|
let unsubscribe;
|
|
153
|
+
let unsubscribeAuthentication;
|
|
29
154
|
let authenticationState;
|
|
155
|
+
let login;
|
|
30
156
|
let disconnectWasClean = false;
|
|
31
157
|
try {
|
|
32
158
|
authenticationState = await authentication.state();
|
|
@@ -34,33 +160,75 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
34
160
|
if (authenticationState.status !== "authenticated") {
|
|
35
161
|
throw new Error(`Adapter contract exercise requires authenticated test state, received ${authenticationState.status}`);
|
|
36
162
|
}
|
|
37
|
-
// The harness knows who is signed in, so it can hold the
|
|
38
|
-
// validators cannot check alone: a roster never carries the
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
163
|
+
// The harness knows who is signed in and what their login declares, so it can hold the
|
|
164
|
+
// adapter to rules the structural validators cannot check alone: a roster never carries the
|
|
165
|
+
// agent it is published to, a lead's snapshot always carries one, nobody else's ever does.
|
|
166
|
+
// Capabilities are current, not fixed, so the login is read when something is validated,
|
|
167
|
+
// never captured at sign-in: a provider that withdraws one republishes `authenticated`, and
|
|
168
|
+
// everything published after that is held to the new set. A published state that fails
|
|
169
|
+
// validation is reported and not adopted -- the harness keeps the last login it could trust.
|
|
170
|
+
login = authenticationState;
|
|
171
|
+
const current = () => {
|
|
172
|
+
if (login === undefined)
|
|
173
|
+
throw new Error("unreachable: the exercise has an authenticated login");
|
|
174
|
+
return login;
|
|
175
|
+
};
|
|
176
|
+
const reader = () => ({ self: current().identity.id, capabilities: current().capabilities });
|
|
42
177
|
// The optional methods are optional only until something declares a need for them. Each
|
|
43
178
|
// check pairs a method with the declaration that requires it, as the guide's Live-connection
|
|
44
179
|
// table does; a missing one is a control the agent would be shown and could never use.
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
180
|
+
const reported = new Set();
|
|
181
|
+
const requireMethod = (on, name, because) => {
|
|
182
|
+
if (typeof on[name] === "function" || reported.has(name))
|
|
183
|
+
return;
|
|
184
|
+
reported.add(name);
|
|
185
|
+
violations.push({
|
|
186
|
+
rule: `connection.${name}.required`,
|
|
187
|
+
path: `connection.${name}`,
|
|
188
|
+
message: `${because}, but the connection does not implement ${name}()`,
|
|
189
|
+
});
|
|
190
|
+
};
|
|
191
|
+
const requireCapabilityMethods = (on, capabilities) => {
|
|
192
|
+
if (capabilities.breaks === true) {
|
|
193
|
+
// The four stand or fall together: `granted` is a promise to honour a later commit, and
|
|
194
|
+
// an adapter with requestBreak but no commitBreak leaves an agent a break that never starts.
|
|
195
|
+
for (const method of ["requestBreak", "commitBreak", "cancelBreak", "endBreak"]) {
|
|
196
|
+
requireMethod(on, method, "the login declares capabilities.breaks");
|
|
197
|
+
}
|
|
52
198
|
}
|
|
199
|
+
if (capabilities.team?.breakControl === true)
|
|
200
|
+
requireMethod(on, "executeTeamBreak", "the login declares capabilities.team.breakControl");
|
|
201
|
+
if (capabilities.team?.consultControl === true)
|
|
202
|
+
requireMethod(on, "executeTeamConsult", "the login declares capabilities.team.consultControl");
|
|
53
203
|
};
|
|
204
|
+
unsubscribeAuthentication = authentication.subscribe(state => {
|
|
205
|
+
const own = validateAuthenticationState(state);
|
|
206
|
+
violations.push(...own);
|
|
207
|
+
if (own.length > 0)
|
|
208
|
+
return;
|
|
209
|
+
if (state.status === "refreshing") {
|
|
210
|
+
violations.push(...refreshingCarriesOver(current(), state, "authentication"));
|
|
211
|
+
}
|
|
212
|
+
else if (state.status === "authenticated") {
|
|
213
|
+
login = state;
|
|
214
|
+
// A capability granted later requires its methods just as one declared at sign-in does.
|
|
215
|
+
if (connection !== undefined)
|
|
216
|
+
requireCapabilityMethods(connection, state.capabilities);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
connection = await adapter.connect(context);
|
|
220
|
+
const live = connection;
|
|
54
221
|
// Dial is declared by presence: the capability object carries a destination policy rather
|
|
55
222
|
// than an `enabled` flag, so its presence is the declaration.
|
|
56
223
|
if (adapter.manifest.idleCapabilities?.dial !== undefined)
|
|
57
|
-
requireMethod("dial", "the manifest declares dial");
|
|
224
|
+
requireMethod(live, "dial", "the manifest declares dial");
|
|
58
225
|
// Every voice task's audio lands in Omni, so there is no voice adapter that does not open it.
|
|
59
226
|
if (adapter.manifest.channel === "voice")
|
|
60
|
-
requireMethod("openMedia", "the manifest channel is voice");
|
|
227
|
+
requireMethod(live, "openMedia", "the manifest channel is voice");
|
|
61
228
|
const eventIds = new Set();
|
|
62
229
|
unsubscribe = connection.subscribe(envelope => {
|
|
63
|
-
|
|
230
|
+
observeEvent(envelope, seen);
|
|
231
|
+
violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader()));
|
|
64
232
|
if (typeof envelope?.id === "string") {
|
|
65
233
|
if (eventIds.has(envelope.id))
|
|
66
234
|
return;
|
|
@@ -69,20 +237,11 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
69
237
|
events.push(envelope);
|
|
70
238
|
});
|
|
71
239
|
const snapshot = await connection.snapshot();
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// an adapter with requestBreak but no commitBreak leaves an agent a break that never starts.
|
|
76
|
-
for (const method of ["requestBreak", "commitBreak", "cancelBreak", "endBreak"]) {
|
|
77
|
-
requireMethod(method, "the snapshot declares sessionCapabilities.breaks");
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
if (snapshot?.team?.breakControl === true)
|
|
81
|
-
requireMethod("executeTeamBreak", "the roster carries breakControl");
|
|
82
|
-
if (snapshot?.team?.consultControl === true)
|
|
83
|
-
requireMethod("executeTeamConsult", "the roster carries consultControl");
|
|
240
|
+
observeSnapshot(snapshot, seen);
|
|
241
|
+
violations.push(...validateSnapshot(snapshot, adapter.manifest, "snapshot", reader()));
|
|
242
|
+
requireCapabilityMethods(live, current().capabilities);
|
|
84
243
|
if (publishesUserIds(snapshot))
|
|
85
|
-
requireMethod("describeUsers", "the snapshot publishes a UserId");
|
|
244
|
+
requireMethod(live, "describeUsers", "the snapshot publishes a UserId");
|
|
86
245
|
// Capacity is stated, not requested: nothing may be allocated until it is, so a connection
|
|
87
246
|
// that will not accept one is a connection nothing can be given to.
|
|
88
247
|
const capacity = await connection.setCapacity({ count: 1 });
|
|
@@ -102,6 +261,12 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
102
261
|
catch {
|
|
103
262
|
clean = false;
|
|
104
263
|
}
|
|
264
|
+
try {
|
|
265
|
+
unsubscribeAuthentication?.();
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
clean = false;
|
|
269
|
+
}
|
|
105
270
|
try {
|
|
106
271
|
await connection?.disconnect();
|
|
107
272
|
}
|
|
@@ -120,7 +285,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
120
285
|
violations.push({
|
|
121
286
|
rule: "connection.disconnect.clean",
|
|
122
287
|
path: "connection.disconnect",
|
|
123
|
-
message: "unsubscribe
|
|
288
|
+
message: "an unsubscribe, disconnect(), or close() threw during shutdown",
|
|
124
289
|
});
|
|
125
290
|
}
|
|
126
291
|
if (!options.collectOnly)
|
|
@@ -128,6 +293,8 @@ export async function exerciseAdapter(adapter, context, options = {}) {
|
|
|
128
293
|
return {
|
|
129
294
|
events: events,
|
|
130
295
|
authenticationState: authenticationState,
|
|
296
|
+
login: (login ?? authenticationState),
|
|
297
|
+
notExercised: CONTRACT_SUBJECTS.filter(subject => !seen.has(subject)),
|
|
131
298
|
disconnectWasClean,
|
|
132
299
|
violations,
|
|
133
300
|
};
|
|
@@ -146,8 +313,55 @@ function publishesUserIds(snapshot) {
|
|
|
146
313
|
return false;
|
|
147
314
|
return snapshot.tasks.some(task => Array.isArray(task?.handlingHistory) && task.handlingHistory.some(step => step?.by !== undefined));
|
|
148
315
|
}
|
|
149
|
-
|
|
316
|
+
const sameCapabilities = (a, b) => a.breaks === b.breaks &&
|
|
317
|
+
(a.team === undefined) === (b.team === undefined) &&
|
|
318
|
+
a.team?.breakControl === b.team?.breakControl &&
|
|
319
|
+
a.team?.consultControl === b.team?.consultControl;
|
|
320
|
+
/**
|
|
321
|
+
* `refreshing` carries the identity and capabilities of the login it refreshes. A change to
|
|
322
|
+
* either is published as `authenticated`; a different identity is a new login.
|
|
323
|
+
*/
|
|
324
|
+
function refreshingCarriesOver(login, state, path) {
|
|
325
|
+
const found = [];
|
|
326
|
+
if (state.identity.id !== login.identity.id) {
|
|
327
|
+
found.push({
|
|
328
|
+
rule: "authentication.refreshing.identity",
|
|
329
|
+
path: `${path}.identity.id`,
|
|
330
|
+
message: "refreshing carries the identity of the login it refreshes; a different identity is a new login",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
if (!sameCapabilities(state.capabilities, login.capabilities)) {
|
|
334
|
+
found.push({
|
|
335
|
+
rule: "authentication.refreshing.capabilities",
|
|
336
|
+
path: `${path}.capabilities`,
|
|
337
|
+
message: "refreshing carries the capabilities of the login it refreshes; a change is published as authenticated",
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
return found;
|
|
341
|
+
}
|
|
342
|
+
/** Validates each state of a published sequence, and that every `refreshing` carries over the login before it. */
|
|
343
|
+
function assertLoginSequence(states, summary) {
|
|
344
|
+
const found = [];
|
|
345
|
+
let login;
|
|
346
|
+
states.forEach((state, index) => {
|
|
347
|
+
const path = `states[${index}]`;
|
|
348
|
+
const own = validateAuthenticationState(state, path);
|
|
349
|
+
found.push(...own);
|
|
350
|
+
if (own.length > 0)
|
|
351
|
+
return;
|
|
352
|
+
if (state.status === "authenticated")
|
|
353
|
+
login = state;
|
|
354
|
+
else if (state.status === "refreshing" && login !== undefined)
|
|
355
|
+
found.push(...refreshingCarriesOver(login, state, path));
|
|
356
|
+
});
|
|
357
|
+
assertNoViolations(found, summary);
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* Validates restored authentication followed by a refresh failure or expiry. Every state is
|
|
361
|
+
* validated, and a `refreshing` state must carry over the login it refreshes.
|
|
362
|
+
*/
|
|
150
363
|
export function assertAuthenticationRestoreAndExpiry(states) {
|
|
364
|
+
assertLoginSequence(states, "Authentication scenario");
|
|
151
365
|
if (states.length < 2 || states[0]?.status !== "authenticated") {
|
|
152
366
|
throw new Error("Authentication scenario must start with a restored authenticated state");
|
|
153
367
|
}
|
|
@@ -160,6 +374,64 @@ export function assertAuthenticationRestoreAndExpiry(states) {
|
|
|
160
374
|
throw new Error("Refreshing state must occur before expiry");
|
|
161
375
|
}
|
|
162
376
|
}
|
|
377
|
+
/**
|
|
378
|
+
* Capabilities are current, not fixed. A provider that withdraws one republishes `authenticated`
|
|
379
|
+
* with the new set, and the next snapshot agrees with it. `states` is what the authentication
|
|
380
|
+
* session published, first to last: beginning and ending `authenticated` for the same identity,
|
|
381
|
+
* passing only through usable states (`expired` or `signed-out` ends the login instead), with at
|
|
382
|
+
* least one capability gone by the end. `snapshot` is the first snapshot published after the last
|
|
383
|
+
* state, validated against that login -- so a roster still published to a login that no longer
|
|
384
|
+
* leads, or requests to one that may no longer join, is the failure.
|
|
385
|
+
*/
|
|
386
|
+
export function assertCapabilityWithdrawal(states, snapshot, manifest) {
|
|
387
|
+
assertLoginSequence(states, "Capability withdrawal");
|
|
388
|
+
const stray = states.findIndex(state => state.status !== "authenticated" && state.status !== "refreshing");
|
|
389
|
+
if (stray >= 0) {
|
|
390
|
+
throw new Error(`Capability withdrawal passes only through usable states; states[${stray}] is ${states[stray]?.status}, which ends the login`);
|
|
391
|
+
}
|
|
392
|
+
const first = states[0];
|
|
393
|
+
const last = states.at(-1);
|
|
394
|
+
if (first?.status !== "authenticated" || last?.status !== "authenticated") {
|
|
395
|
+
throw new Error("Capability withdrawal must begin and end with an authenticated login");
|
|
396
|
+
}
|
|
397
|
+
if (first.identity.id !== last.identity.id) {
|
|
398
|
+
throw new Error("Capability withdrawal must keep the identity: a different identity is a new login");
|
|
399
|
+
}
|
|
400
|
+
const before = first.capabilities;
|
|
401
|
+
const after = last.capabilities;
|
|
402
|
+
const withdrawn = (before.breaks === true && after.breaks !== true) ||
|
|
403
|
+
(before.team !== undefined && after.team === undefined) ||
|
|
404
|
+
(before.team?.breakControl === true && after.team?.breakControl !== true) ||
|
|
405
|
+
(before.team?.consultControl === true && after.team?.consultControl !== true);
|
|
406
|
+
if (!withdrawn) {
|
|
407
|
+
throw new Error("Capability withdrawal must end with at least one capability the first login declared withdrawn");
|
|
408
|
+
}
|
|
409
|
+
assertNoViolations(validateSnapshot(snapshot, manifest, "snapshot", { self: last.identity.id, capabilities: after }), "Capability withdrawal: the snapshot after the last login must agree with it");
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* A command that arrives after its capability was withdrawn is answered `failed` with
|
|
413
|
+
* `omni.capability-not-enabled`: the provider names it, so Omni never has to infer from a
|
|
414
|
+
* capability change it may not have rendered yet that "no longer a lead" is the message.
|
|
415
|
+
* Takes any command result -- a team command, a break method -- after such a withdrawal.
|
|
416
|
+
*/
|
|
417
|
+
export function assertCommandRefusedAfterWithdrawal(result) {
|
|
418
|
+
if (result.status !== "failed") {
|
|
419
|
+
throw new Error(`A command after its capability was withdrawn must fail; received ${result.status}`);
|
|
420
|
+
}
|
|
421
|
+
if (result.failure?.code !== "omni.capability-not-enabled") {
|
|
422
|
+
throw new Error(`A command after its capability was withdrawn fails with omni.capability-not-enabled, not ${result.failure?.code}`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Throws unless the run reached every subject named: the paired assertion beside a clean result,
|
|
427
|
+
* so a fixture that never produced a roster cannot pass a test that meant to check one.
|
|
428
|
+
*/
|
|
429
|
+
export function assertReached(result, subjects) {
|
|
430
|
+
const missed = subjects.filter(subject => result.notExercised.includes(subject));
|
|
431
|
+
if (missed.length > 0) {
|
|
432
|
+
throw new Error(`The exercise never reached ${missed.join(", ")}: its clean result says nothing about them`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
163
435
|
/** Validates duplicate delivery and returns the event sequence Omni applies once per ID. */
|
|
164
436
|
export function assertDuplicateEventDelivery(envelopes) {
|
|
165
437
|
const byId = new Map();
|
package/dist/validation.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type ProtocolViolation, type UserId } from "./index.js";
|
|
1
|
+
import { type ProtocolViolation, type SessionCapabilities, type UserId } from "./index.js";
|
|
2
2
|
export type { ProtocolViolation } from "./index.js";
|
|
3
3
|
export declare class ProtocolConformanceError extends Error {
|
|
4
4
|
readonly violations: readonly ProtocolViolation[];
|
|
@@ -16,8 +16,8 @@ export interface TaskValidationContext {
|
|
|
16
16
|
}
|
|
17
17
|
export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
|
|
18
18
|
/**
|
|
19
|
-
* Who is reading what the adapter published. The validators check
|
|
20
|
-
*
|
|
19
|
+
* Who is reading what the adapter published, and what their login declares. The validators check
|
|
20
|
+
* structure without it; given it, they also hold what the adapter publishes to the login.
|
|
21
21
|
*/
|
|
22
22
|
export interface ReaderContext {
|
|
23
23
|
/**
|
|
@@ -25,6 +25,11 @@ export interface ReaderContext {
|
|
|
25
25
|
* a roster that lists them in `members`, or their own ask in `requests`, is a violation.
|
|
26
26
|
*/
|
|
27
27
|
self?: UserId;
|
|
28
|
+
/**
|
|
29
|
+
* The login's `AuthenticationState.capabilities`. The login is the permission: a lead's
|
|
30
|
+
* snapshot carries a roster, nobody else's does, and `requests` need `team.consultControl`.
|
|
31
|
+
*/
|
|
32
|
+
capabilities?: SessionCapabilities;
|
|
28
33
|
}
|
|
29
34
|
export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
|
30
35
|
export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
|
package/dist/validation.js
CHANGED
|
@@ -69,7 +69,8 @@ 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,
|
|
72
|
+
const SESSION_CAPABILITIES = membersOf({ breaks: true, team: true });
|
|
73
|
+
const TEAM_CAPABILITIES = membersOf({ breakControl: true, consultControl: true });
|
|
73
74
|
const COMPLETED_BY = membersOf({ agent: true, provider: true });
|
|
74
75
|
const EXPIRABLE_PHASES = membersOf({
|
|
75
76
|
pending: true, confirmed: true, preparing: true,
|
|
@@ -715,15 +716,24 @@ function validateTeamRosterInto(roster, path, context, into) {
|
|
|
715
716
|
into.add("team.shape", path, "a team roster must be an object");
|
|
716
717
|
return;
|
|
717
718
|
}
|
|
718
|
-
|
|
719
|
-
|
|
719
|
+
// The login is the permission: a roster reaches a login that declares `capabilities.team` and
|
|
720
|
+
// nobody else. Only a caller holding the login can check it.
|
|
721
|
+
if (context.capabilities !== undefined && context.capabilities.team === undefined) {
|
|
722
|
+
into.add("team.unentitled", path, "a roster published to a login that does not declare capabilities.team: the login is the permission");
|
|
720
723
|
}
|
|
721
|
-
if (roster.
|
|
722
|
-
|
|
724
|
+
if (roster.requests === undefined) {
|
|
725
|
+
// `[]` says nobody is asking; omission says the lead may not be asked. A login that may be
|
|
726
|
+
// asked therefore always carries the list.
|
|
727
|
+
if (context.capabilities?.team?.consultControl === true) {
|
|
728
|
+
into.add("team.requests.required", `${path}.requests`, "the login declares team.consultControl, so the roster carries requests: [] when nobody is asking");
|
|
729
|
+
}
|
|
723
730
|
}
|
|
724
|
-
|
|
731
|
+
else {
|
|
725
732
|
// Requests are what a lead acts on, so a lead who may not act has no business receiving them.
|
|
726
|
-
|
|
733
|
+
// Whether they may is on the login, so the check needs the login in hand.
|
|
734
|
+
if (context.capabilities !== undefined) {
|
|
735
|
+
into.require(context.capabilities.team?.consultControl === true, "team.requests.capability", `${path}.requests`, "requests require team.consultControl on the login: a lead who may not join has nothing to decide");
|
|
736
|
+
}
|
|
727
737
|
if (!Array.isArray(roster.requests)) {
|
|
728
738
|
into.add("team.requests.shape", `${path}.requests`, "requests must be an array when present");
|
|
729
739
|
}
|
|
@@ -783,19 +793,6 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
783
793
|
const channel = isPlainObject(manifest) && typeof manifest.channel === "string" ? manifest.channel : "voice";
|
|
784
794
|
into.oneOf(snapshot.status, CONNECTION_STATUSES, "snapshot.status", `${path}.status`);
|
|
785
795
|
into.filled(snapshot.sessionId, "snapshot.sessionId", `${path}.sessionId`, "a snapshot needs the session id it belongs to");
|
|
786
|
-
const sessionCapabilities = snapshot.sessionCapabilities;
|
|
787
|
-
if (!isPlainObject(sessionCapabilities)) {
|
|
788
|
-
into.add("snapshot.sessionCapabilities.shape", `${path}.sessionCapabilities`, "a snapshot needs a sessionCapabilities object");
|
|
789
|
-
}
|
|
790
|
-
else {
|
|
791
|
-
for (const [name, declared] of Object.entries(sessionCapabilities)) {
|
|
792
|
-
if (declared === undefined)
|
|
793
|
-
continue;
|
|
794
|
-
if (!into.require(SESSION_CAPABILITIES.includes(name), "snapshot.sessionCapability.unknown", `${path}.sessionCapabilities.${name}`, `unsupported session capability: ${name}`))
|
|
795
|
-
continue;
|
|
796
|
-
into.require(declared === true, "snapshot.sessionCapability.value", `${path}.sessionCapabilities.${name}`, `${name} is declared by presence: send true or omit it`);
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
796
|
validateBreakState(snapshot.break, `${path}.break`, into);
|
|
800
797
|
if (!Array.isArray(snapshot.tasks)) {
|
|
801
798
|
into.add("snapshot.tasks.shape", `${path}.tasks`, "a snapshot must carry a tasks array");
|
|
@@ -814,6 +811,15 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
814
811
|
// Presence is the permission, and it cuts both ways: data a provider never declared a
|
|
815
812
|
// capability for is data Omni would show against a control the agent does not have.
|
|
816
813
|
const idle = isPlainObject(manifest) && isPlainObject(manifest.idleCapabilities) ? manifest.idleCapabilities : {};
|
|
814
|
+
// And it cuts the other way too: a declared contribution is required, `[]` included. A snapshot
|
|
815
|
+
// that omits one the manifest declares has not cleared it, it has said nothing, and Omni would
|
|
816
|
+
// go on showing whatever it held.
|
|
817
|
+
if (snapshot.contacts === undefined && idle.contacts === true) {
|
|
818
|
+
into.add("snapshot.contacts.required", `${path}.contacts`, "the manifest declares contacts, so every snapshot carries the contribution: [] when there are none");
|
|
819
|
+
}
|
|
820
|
+
if (snapshot.scheduledActivities === undefined && idle.calendar === true) {
|
|
821
|
+
into.add("snapshot.calendar.required", `${path}.scheduledActivities`, "the manifest declares calendar, so every snapshot carries the contribution: [] when there are none");
|
|
822
|
+
}
|
|
817
823
|
if (snapshot.contacts !== undefined) {
|
|
818
824
|
into.require(idle.contacts === true, "snapshot.contacts.capability", `${path}.contacts`, "contacts require the contacts idle capability");
|
|
819
825
|
if (Array.isArray(snapshot.contacts)) {
|
|
@@ -841,6 +847,11 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
|
|
|
841
847
|
into.add("snapshot.calendar.shape", `${path}.scheduledActivities`, "scheduledActivities must be an array when present");
|
|
842
848
|
}
|
|
843
849
|
}
|
|
850
|
+
// The login is the permission: a lead's snapshot carries a roster, `[]` included. The other
|
|
851
|
+
// direction -- a roster to a login that does not lead -- is the roster's own rule.
|
|
852
|
+
if (context.capabilities?.team !== undefined && snapshot.team === undefined) {
|
|
853
|
+
into.add("team.required", `${path}.team`, "the login declares capabilities.team, so every snapshot carries a roster: [] when nobody is in it");
|
|
854
|
+
}
|
|
844
855
|
if (snapshot.team !== undefined)
|
|
845
856
|
validateTeamRosterInto(snapshot.team, `${path}.team`, context, into);
|
|
846
857
|
return into.violations;
|
|
@@ -1012,6 +1023,33 @@ function validateUser(value, rule, path, into) {
|
|
|
1012
1023
|
into.require(isUserId(value.id), `${rule}.id`, `${path}.id`, "an identity needs a provider-issued user id");
|
|
1013
1024
|
into.filled(value.displayName, `${rule}.displayName`, `${path}.displayName`, "an identity needs a display name");
|
|
1014
1025
|
}
|
|
1026
|
+
function validateSessionCapabilitiesInto(value, path, into) {
|
|
1027
|
+
if (!isPlainObject(value)) {
|
|
1028
|
+
into.add("authentication.capabilities.shape", path, "a usable login declares its capabilities: an object, {} when it has none");
|
|
1029
|
+
return;
|
|
1030
|
+
}
|
|
1031
|
+
for (const [name, declared] of Object.entries(value)) {
|
|
1032
|
+
if (declared === undefined)
|
|
1033
|
+
continue;
|
|
1034
|
+
if (!into.require(SESSION_CAPABILITIES.includes(name), "authentication.capability.unknown", `${path}.${name}`, `unsupported session capability: ${name}`))
|
|
1035
|
+
continue;
|
|
1036
|
+
if (name === "team") {
|
|
1037
|
+
if (!isPlainObject(declared)) {
|
|
1038
|
+
into.add("authentication.capability.team.shape", `${path}.team`, "team names what the lead may do: an object, {} for a lead with no controls");
|
|
1039
|
+
continue;
|
|
1040
|
+
}
|
|
1041
|
+
for (const [control, on] of Object.entries(declared)) {
|
|
1042
|
+
if (on === undefined)
|
|
1043
|
+
continue;
|
|
1044
|
+
if (!into.require(TEAM_CAPABILITIES.includes(control), "authentication.capability.team.unknown", `${path}.team.${control}`, `unsupported team capability: ${control}`))
|
|
1045
|
+
continue;
|
|
1046
|
+
into.require(on === true, "authentication.capability.value", `${path}.team.${control}`, `${control} is declared by presence: send true or omit it`);
|
|
1047
|
+
}
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
into.require(declared === true, "authentication.capability.value", `${path}.${name}`, `${name} is declared by presence: send true or omit it`);
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1015
1053
|
export function validateAuthenticationState(state, path = "authentication") {
|
|
1016
1054
|
const into = new Collector();
|
|
1017
1055
|
if (!isPlainObject(state)) {
|
|
@@ -1025,6 +1063,7 @@ export function validateAuthenticationState(state, path = "authentication") {
|
|
|
1025
1063
|
// may carry an identity. Anything else is a state claiming knowledge it does not have.
|
|
1026
1064
|
if (state.status === "authenticated" || state.status === "refreshing") {
|
|
1027
1065
|
validateUser(state.identity, "authentication.identity", `${path}.identity`, into);
|
|
1066
|
+
validateSessionCapabilitiesInto(state.capabilities, `${path}.capabilities`, into);
|
|
1028
1067
|
}
|
|
1029
1068
|
else if (state.status === "expired") {
|
|
1030
1069
|
if (state.identity !== undefined)
|
|
@@ -1047,5 +1086,8 @@ export function validateAuthenticationState(state, path = "authentication") {
|
|
|
1047
1086
|
into.require(state.status === "authenticated", "authentication.expiresAt.unexpected", `${path}.expiresAt`, "only an authenticated state may carry an expiry");
|
|
1048
1087
|
into.timestamp(state.expiresAt, "authentication.expiresAt", `${path}.expiresAt`);
|
|
1049
1088
|
}
|
|
1089
|
+
if (state.status !== "authenticated" && state.status !== "refreshing") {
|
|
1090
|
+
into.require(state.capabilities === undefined, "authentication.capabilities.unexpected", `${path}.capabilities`, `${state.status} must not carry capabilities`);
|
|
1091
|
+
}
|
|
1050
1092
|
return into.violations;
|
|
1051
1093
|
}
|
package/guide.md
CHANGED
|
@@ -21,7 +21,7 @@ are used precisely throughout and mean nothing looser here.
|
|
|
21
21
|
| **Provider** | One independently connected external system: a voice platform, a chat platform, a mail platform. |
|
|
22
22
|
| **Adapter** | The package implementing this contract for one provider. One adapter is one provider, so the words are often interchangeable; *provider* names the system, *adapter* the code speaking for it. |
|
|
23
23
|
| **Agent** | The person signed in and taking work. Not to be confused with a transfer destination whose `kind` is `agent`, which is a routing target. |
|
|
24
|
-
| **Lead** | An agent
|
|
24
|
+
| **Lead** | An agent whose login declares `capabilities.team`. The provider publishes a `TeamRoster` to them and to nobody else: **the login is the permission**. |
|
|
25
25
|
| **Provisioning** | Omni-side policy about this agent, configured outside the protocol and never sent to a provider. It gates whether an offer may be rejected, whether the agent goes ready on login, and whether tasks are auto-accepted. Where a capability and provisioning disagree, the stricter wins. |
|
|
26
26
|
| **Task** | One unit of assigned work — a call, a chat, a mail. |
|
|
27
27
|
| **Channel** | The kind of work a provider carries: `voice`, `chat`, or `email`. Fixed per provider by its manifest. |
|
|
@@ -220,11 +220,21 @@ type AuthenticationContext = {
|
|
|
220
220
|
log?: (entry: unknown) => void;
|
|
221
221
|
};
|
|
222
222
|
|
|
223
|
+
type TeamCapabilities = {
|
|
224
|
+
breakControl?: true;
|
|
225
|
+
consultControl?: true;
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
type SessionCapabilities = {
|
|
229
|
+
breaks?: true;
|
|
230
|
+
team?: TeamCapabilities;
|
|
231
|
+
};
|
|
232
|
+
|
|
223
233
|
type AuthenticationState =
|
|
224
234
|
| { status: "signed-out" }
|
|
225
235
|
| { status: "authenticating" }
|
|
226
|
-
| { status: "authenticated"; identity: User; expiresAt?: IsoTimestamp }
|
|
227
|
-
| { status: "refreshing"; identity: User }
|
|
236
|
+
| { status: "authenticated"; identity: User; capabilities: SessionCapabilities; expiresAt?: IsoTimestamp }
|
|
237
|
+
| { status: "refreshing"; identity: User; capabilities: SessionCapabilities }
|
|
228
238
|
| { status: "expired"; identity?: User; failure?: AuthenticationFailure };
|
|
229
239
|
|
|
230
240
|
type AuthenticationFailure = {
|
|
@@ -249,15 +259,9 @@ type ConnectionStatus = "connecting" | "active" | "error";
|
|
|
249
259
|
### Provider state
|
|
250
260
|
|
|
251
261
|
```ts
|
|
252
|
-
type SessionCapabilities = {
|
|
253
|
-
breaks?: true;
|
|
254
|
-
teamBreakControl?: true;
|
|
255
|
-
};
|
|
256
|
-
|
|
257
262
|
type Snapshot = {
|
|
258
263
|
status: ConnectionStatus;
|
|
259
264
|
sessionId: string;
|
|
260
|
-
sessionCapabilities: SessionCapabilities;
|
|
261
265
|
break: BreakState;
|
|
262
266
|
tasks: Task[];
|
|
263
267
|
contacts?: Contact[];
|
|
@@ -608,8 +612,6 @@ type LeadRequest = {
|
|
|
608
612
|
|
|
609
613
|
type TeamRoster = {
|
|
610
614
|
members: TeamMember[];
|
|
611
|
-
breakControl?: true;
|
|
612
|
-
consultControl?: true;
|
|
613
615
|
requests?: LeadRequest[];
|
|
614
616
|
};
|
|
615
617
|
|
|
@@ -1306,8 +1308,8 @@ It carries no identity: who the agent is on this provider is the outcome of auth
|
|
|
1306
1308
|
input to it.
|
|
1307
1309
|
|
|
1308
1310
|
Closing this session releases observers and temporary flow state; it does not sign the agent out.
|
|
1309
|
-
Omni keeps the session open while the provider connection is active so refresh
|
|
1310
|
-
remain observable.
|
|
1311
|
+
Omni keeps the session open while the provider connection is active so refresh, expiry, and
|
|
1312
|
+
capability changes remain observable.
|
|
1311
1313
|
|
|
1312
1314
|
### Authentication state
|
|
1313
1315
|
|
|
@@ -1318,14 +1320,53 @@ reports later changes.
|
|
|
1318
1320
|
| --- | --- |
|
|
1319
1321
|
| `signed-out` | No usable provider session exists. |
|
|
1320
1322
|
| `authenticating` | An interactive `browser-sso` or `credentials` flow is active. |
|
|
1321
|
-
| `authenticated` | A usable session exists. Includes the provider identity and optional token expiry time. |
|
|
1322
|
-
| `refreshing` | The adapter is refreshing its session. Existing provider identity
|
|
1323
|
+
| `authenticated` | A usable session exists. Includes the provider identity, the login's `capabilities`, and optional token expiry time. |
|
|
1324
|
+
| `refreshing` | The adapter is refreshing its session. Existing provider identity and capabilities remain available, unchanged: a change to either is published as `authenticated`. |
|
|
1323
1325
|
| `expired` | The session cannot currently be used. It may include an identity and typed failure. |
|
|
1324
1326
|
|
|
1325
1327
|
Omni calls `connect()` only after authentication reaches `authenticated`. Token refresh remains
|
|
1326
1328
|
adapter-owned; the adapter publishes `refreshing`, followed by `authenticated` or `expired`.
|
|
1327
|
-
|
|
1328
|
-
reauthentication for that
|
|
1329
|
+
`refreshing` asks nothing of the agent and Omni shows nothing for it. If authentication expires
|
|
1330
|
+
during active work, Omni preserves the task workspace and shows reauthentication for that
|
|
1331
|
+
provider: the transport is still up and the tasks still on it, so this is a login problem with a
|
|
1332
|
+
login fix, rendered apart from a provider Omni cannot reach. Commands meanwhile answer
|
|
1333
|
+
`omni.not-authenticated`.
|
|
1334
|
+
|
|
1335
|
+
**Re-authentication restores the login; it does not replace it.** It runs on the session Omni
|
|
1336
|
+
kept, under the same `sessionId` — the old `flowId` died with the expiry, so the adapter issues a
|
|
1337
|
+
new challenge — and when the state returns to `authenticated` the connection and everything on it
|
|
1338
|
+
carry on: Omni does not call `connect()` again, since a second connection would be a second
|
|
1339
|
+
session for one agent. What "signing in again replaces the login" describes is a new
|
|
1340
|
+
`AuthenticationSession` under a new `sessionId`, after `signed-out`.
|
|
1341
|
+
|
|
1342
|
+
### What the login may do
|
|
1343
|
+
|
|
1344
|
+
`capabilities` declares provider actions available to this login rather than to one task: whether
|
|
1345
|
+
the agent may ask for a break, and whether they lead a team — and if so, whether they decide its
|
|
1346
|
+
breaks and whether they may join a member's call. It is declared by presence, like every capability
|
|
1347
|
+
in this contract, and it travels with the identity because it is part of who the agent is on this
|
|
1348
|
+
provider: the provider knows the roles, and says so at sign-in rather than leaving Omni to infer
|
|
1349
|
+
them from what arrives later.
|
|
1350
|
+
|
|
1351
|
+
| Field | Contract |
|
|
1352
|
+
| --- | --- |
|
|
1353
|
+
| `breaks` | This login may request a break. Requires the four break methods on the connection. |
|
|
1354
|
+
| `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. |
|
|
1355
|
+
| `team.breakControl` | This lead decides their team's breaks. Requires `executeTeamBreak`. |
|
|
1356
|
+
| `team.consultControl` | This lead may join a member's call on request. Requires `executeTeamConsult`. |
|
|
1357
|
+
|
|
1358
|
+
A session action is available only when both the capability and Omni provisioning permit it.
|
|
1359
|
+
|
|
1360
|
+
**Capabilities are current, not fixed.** They describe the login as of its latest `authenticated`
|
|
1361
|
+
state. A provider that reads roles live — a lead demoted mid-shift — republishes `authenticated`
|
|
1362
|
+
with the new set through `subscribe()` on the authentication session, which Omni keeps open for
|
|
1363
|
+
the life of the connection for exactly this reason, and the next snapshot agrees with it. Omni
|
|
1364
|
+
provisions what the capabilities call for at sign-in — a team panel for a lead, empty until the
|
|
1365
|
+
roster arrives, and nothing for anybody else — and withdraws it on the next render when the
|
|
1366
|
+
capability goes. A command that arrives after its capability was withdrawn is answered `failed`
|
|
1367
|
+
with `omni.capability-not-enabled`: the provider names it, so Omni never has to infer from a
|
|
1368
|
+
capability change it may not have rendered yet that "you are no longer a lead" is the message
|
|
1369
|
+
rather than "that did not work".
|
|
1329
1370
|
|
|
1330
1371
|
### Starting authentication
|
|
1331
1372
|
|
|
@@ -1391,14 +1432,16 @@ settles. The adapter must not persist raw credentials. Field-specific failures m
|
|
|
1391
1432
|
|
|
1392
1433
|
`cancelAuthentication(flowId)` cancels an abandoned Browser SSO window or credentials form and
|
|
1393
1434
|
releases its temporary state. It does not sign out an already authenticated session. It answers
|
|
1394
|
-
`
|
|
1435
|
+
`accepted`; a repeat, or a flow that already ended, is nothing to act on and answers `accepted`
|
|
1436
|
+
too, by the rule that a command asking for a state answers success when that state holds.
|
|
1395
1437
|
|
|
1396
1438
|
### Completion and failures
|
|
1397
1439
|
|
|
1398
|
-
`complete()` returns either
|
|
1440
|
+
`complete()` returns either the authenticated state — identity and capabilities — or a typed
|
|
1441
|
+
failure:
|
|
1399
1442
|
|
|
1400
1443
|
```ts
|
|
1401
|
-
{ status: "authenticated", identity: { id: "1042", displayName: "Asha Rao" } }
|
|
1444
|
+
{ status: "authenticated", identity: { id: "1042", displayName: "Asha Rao" }, capabilities: { breaks: true } }
|
|
1402
1445
|
```
|
|
1403
1446
|
|
|
1404
1447
|
The `User` it carries is the **root of this provider's user namespace**. Every other person this
|
|
@@ -1416,7 +1459,7 @@ authorization codes, tokens, or provider responses containing secrets.
|
|
|
1416
1459
|
|
|
1417
1460
|
### Sign-out
|
|
1418
1461
|
|
|
1419
|
-
`signOut(
|
|
1462
|
+
`signOut()` revokes or invalidates the provider session where supported, deletes stored
|
|
1420
1463
|
session secrets, and moves state to `signed-out`.
|
|
1421
1464
|
`close()` stops authentication-state observation but does not sign the agent out.
|
|
1422
1465
|
|
|
@@ -1462,21 +1505,19 @@ because Omni renders that form itself. That is a local convenience and never rea
|
|
|
1462
1505
|
|
|
1463
1506
|
### `Snapshot`
|
|
1464
1507
|
|
|
1465
|
-
`
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
The snapshot replaces the set completely, so a resync can grant or withdraw a capability safely.
|
|
1508
|
+
What the login may do is declared on its `AuthenticationState` — see **What the login may do** —
|
|
1509
|
+
not here. The snapshot carries what the provider holds for the agent now, and where that depends on
|
|
1510
|
+
a capability it agrees with the login: a lead's snapshot carries `team`, nobody else's does.
|
|
1469
1511
|
|
|
1470
1512
|
| Field | Contract |
|
|
1471
1513
|
| --- | --- |
|
|
1472
1514
|
| `status` | Current `ConnectionStatus` — whether this provider's transport can serve the session. Defined under **`provider-status`**. |
|
|
1473
1515
|
| `sessionId` | Identity of this login session. It must match the connection context. |
|
|
1474
|
-
| `sessionCapabilities` | Complete provider capability set for this login. Effective permission is its intersection with Omni provisioning. |
|
|
1475
1516
|
| `break` | Complete break state, including approval, accepting state, reasons, retry details, and any imposed break. |
|
|
1476
1517
|
| `tasks` | Complete set of tasks currently offered to or owned by this agent. |
|
|
1477
1518
|
| `contacts` | Required complete contact contribution when the manifest declares `contacts`; `[]` clears it. Omitted only when it does not. |
|
|
1478
1519
|
| `scheduledActivities` | Required complete calendar contribution when the manifest declares `calendar`; `[]` clears it. Omitted only when it does not. |
|
|
1479
|
-
| `team` | `TeamRoster`
|
|
1520
|
+
| `team` | Required `TeamRoster` when the login declares `capabilities.team`, `[]` when nobody is in it. Forbidden otherwise — the login is the permission. |
|
|
1480
1521
|
|
|
1481
1522
|
## Live connection
|
|
1482
1523
|
|
|
@@ -1493,15 +1534,15 @@ surface in one place, and what obliges an adapter to implement each one.
|
|
|
1493
1534
|
| `execute(request)` | Always. Every channel has commands no capability gates — see **Which commands need a capability**. |
|
|
1494
1535
|
| `describeUsers(ids)` | The adapter publishes any `UserId`: on `ImposedBreak.by`, a roster, or `handlingHistory[].by`. |
|
|
1495
1536
|
| `dial(request)` | The manifest declares `idleCapabilities.dial`. |
|
|
1496
|
-
| `requestBreak(request)` | `
|
|
1497
|
-
| `commitBreak()` | `
|
|
1498
|
-
| `cancelBreak()` | `
|
|
1499
|
-
| `endBreak()` | `
|
|
1500
|
-
| `executeTeamBreak(command)` | The
|
|
1501
|
-
| `executeTeamConsult(command)` | The
|
|
1537
|
+
| `requestBreak(request)` | The login declares `capabilities.breaks`. |
|
|
1538
|
+
| `commitBreak()` | The login declares `capabilities.breaks`. Commit and cancel are not optional halves of it. |
|
|
1539
|
+
| `cancelBreak()` | The login declares `capabilities.breaks`. |
|
|
1540
|
+
| `endBreak()` | The login declares `capabilities.breaks`. |
|
|
1541
|
+
| `executeTeamBreak(command)` | The login declares `capabilities.team.breakControl`. |
|
|
1542
|
+
| `executeTeamConsult(command)` | The login declares `capabilities.team.consultControl`. |
|
|
1502
1543
|
| `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. |
|
|
1503
1544
|
|
|
1504
|
-
**The four break methods stand or fall together.** Declaring `
|
|
1545
|
+
**The four break methods stand or fall together.** Declaring `capabilities.breaks` at login and then
|
|
1505
1546
|
implementing `requestBreak` without `commitBreak` leaves an agent granted a break that can never
|
|
1506
1547
|
start, and the two-phase coordination in **Coordinating a multi-provider break** has no way to
|
|
1507
1548
|
report that: `granted` is a promise to honour a later commit.
|
|
@@ -2429,7 +2470,7 @@ outstanding and offers **Cancel break request**, but does not tell the agent tha
|
|
|
2429
2470
|
begun.
|
|
2430
2471
|
|
|
2431
2472
|
Omni offers the aggregate Break control only when every provider currently holding capacity
|
|
2432
|
-
declares `
|
|
2473
|
+
declares `capabilities.breaks` at login. If one cannot be stopped, offering a global break would
|
|
2433
2474
|
knowingly permit partial availability.
|
|
2434
2475
|
|
|
2435
2476
|
Omni coordinates one attempt as follows:
|
|
@@ -2553,10 +2594,8 @@ A lead who also takes calls sees their team on the idle dashboard. `Snapshot.tea
|
|
|
2553
2594
|
|
|
2554
2595
|
| Field | Contract |
|
|
2555
2596
|
| --- | --- |
|
|
2556
|
-
| `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 **
|
|
2557
|
-
| `
|
|
2558
|
-
| `consultControl` | Present when this lead may join a member's call on request, absent when they may not. |
|
|
2559
|
-
| `requests` | The members currently asking this lead to join a call, each with the task and the note. Omitted when the lead may not be asked; `[]` when nobody is asking. See **Consulting a lead**. |
|
|
2597
|
+
| `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. |
|
|
2598
|
+
| `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**. |
|
|
2560
2599
|
|
|
2561
2600
|
| `TeamMember` field | Contract |
|
|
2562
2601
|
| --- | --- |
|
|
@@ -2585,19 +2624,20 @@ everybody — worse than showing nothing, because it looks like data. Send it on
|
|
|
2585
2624
|
knows when the state actually began. It times the current `availability`, so it moves every time
|
|
2586
2625
|
that value does.
|
|
2587
2626
|
|
|
2588
|
-
**
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2627
|
+
**The login is the permission.** A roster goes to a login that declares `capabilities.team`, on
|
|
2628
|
+
every snapshot, and to nobody else. Omni never decides who leads a team: the provider said so at
|
|
2629
|
+
sign-in, and the roster agrees with it — present, `[]` included, for a lead; absent for everybody
|
|
2630
|
+
else, which is the correct rendering for an agent who leads nobody. What the lead may do with the
|
|
2631
|
+
roster is on the login too, `team.breakControl` and `team.consultControl`, never on the roster.
|
|
2592
2632
|
|
|
2593
2633
|
**The roster never carries the agent it is published to — not in `members`, and not in
|
|
2594
2634
|
`requests`.** A lead does not report to themself: their own break request and their own ask for a
|
|
2595
2635
|
lead go up to whoever leads them and appear on *that* person's roster, while the requester sees
|
|
2596
2636
|
only their own `BreakState` and their task's `lead` move. An adapter whose platform lists the lead
|
|
2597
|
-
among their own members filters the signed-in identity out before publishing. **
|
|
2598
|
-
role the provider knows, never inferred from who is listed:**
|
|
2599
|
-
publishes `[]`, an agent with no such role publishes nothing, and no
|
|
2600
|
-
two apart.
|
|
2637
|
+
among their own members filters the signed-in identity out before publishing. **Being a lead is a
|
|
2638
|
+
role the provider knows, never inferred from who is listed:** it is declared at sign-in, a lead
|
|
2639
|
+
with nobody in their team publishes `[]`, an agent with no such role publishes nothing, and no
|
|
2640
|
+
member count can tell those two apart.
|
|
2601
2641
|
|
|
2602
2642
|
### Lead commands
|
|
2603
2643
|
|
|
@@ -2634,8 +2674,8 @@ team, and a second lead method beside `executeTeamBreak`:
|
|
|
2634
2674
|
executeTeamConsult({ command: TeamConsultCommand }): Promise<TeamCommandResult>
|
|
2635
2675
|
```
|
|
2636
2676
|
|
|
2637
|
-
Required when the
|
|
2638
|
-
is by `breakControl`. The flow, in order:
|
|
2677
|
+
Required when the login declares `capabilities.team.consultControl`, and gated by it exactly as
|
|
2678
|
+
`executeTeamBreak` is by `team.breakControl`. The flow, in order:
|
|
2639
2679
|
|
|
2640
2680
|
```ts
|
|
2641
2681
|
// 1. The agent asks, with a small note. Their task carries `lead` from here on.
|
|
@@ -2718,8 +2758,8 @@ whole and replaced whole, and a provider that cannot say omits it.
|
|
|
2718
2758
|
|
|
2719
2759
|
**An agent is not waiting on one person.** Authority is held by several, everyone who holds it
|
|
2720
2760
|
sees the request on their own console, and **any one of them settles it**. Omni offers the
|
|
2721
|
-
decision to
|
|
2722
|
-
|
|
2761
|
+
decision to every login that declares `team.breakControl` — which is how the provider already
|
|
2762
|
+
says who may decide — and does not try to work out whose turn it is.
|
|
2723
2763
|
|
|
2724
2764
|
A request needing *more than one* approval is not something this contract describes. There is
|
|
2725
2765
|
no partial state to report and no progress to display: a request is either still owed a
|
|
@@ -2896,8 +2936,8 @@ react rather than only display the message:
|
|
|
2896
2936
|
|
|
2897
2937
|
| Code | Meaning |
|
|
2898
2938
|
| --- | --- |
|
|
2899
|
-
| `omni.not-authenticated` | The provider session is no longer usable. Omni surfaces reauthentication. |
|
|
2900
|
-
| `omni.capability-not-enabled` | The action targets a capability this task or
|
|
2939
|
+
| `omni.not-authenticated` | The provider session is no longer usable. The adapter has published `expired` at or before this answer — the state is what Omni surfaces reauthentication from; the code says why this action failed, and is never the only signal. |
|
|
2940
|
+
| `omni.capability-not-enabled` | The action targets a capability this task, manifest, or login did not declare — including a lead command from a login whose `capabilities` no longer carry it. |
|
|
2901
2941
|
| `omni.task-not-found` | The provider-local task id is unknown, typically after the task already ended. |
|
|
2902
2942
|
| `omni.destination-not-permitted` | The dial or transfer destination violates the provider's policy. |
|
|
2903
2943
|
| `omni.rate-limited` | The action was throttled. Pair with `retryAfterMs`. |
|
|
@@ -2967,9 +3007,10 @@ direction — the provider asking Omni to reconcile — and neither replaces the
|
|
|
2967
3007
|
|
|
2968
3008
|
Carries a complete `Snapshot` after reconnect or when the provider explicitly requests
|
|
2969
3009
|
reconciliation. `reason` is `reconnected` or `provider-requested`. Omni replaces the provider's
|
|
2970
|
-
current status,
|
|
2971
|
-
|
|
2972
|
-
|
|
3010
|
+
current status, break state, tasks, contacts, scheduled activities and team roster with this
|
|
3011
|
+
snapshot. It carries what the login's capabilities call for — a roster for a lead, on every
|
|
3012
|
+
snapshot — and nothing they do not; a capability is withdrawn by a republished `authenticated`,
|
|
3013
|
+
never by an omission from a snapshot.
|
|
2973
3014
|
|
|
2974
3015
|
### `provider-status`
|
|
2975
3016
|
|
|
@@ -3067,7 +3108,8 @@ each connected provider.
|
|
|
3067
3108
|
|
|
3068
3109
|
Replaces this provider's complete `TeamRoster`. It is emitted only for an agent the provider
|
|
3069
3110
|
publishes a roster to, and it carries the whole team every time — never a change to it, for the
|
|
3070
|
-
reason set out under **Team leads**.
|
|
3111
|
+
reason set out under **Team leads**. A lead's snapshot always carries the roster; it goes only when
|
|
3112
|
+
a republished `authenticated` no longer declares `capabilities.team`.
|
|
3071
3113
|
|
|
3072
3114
|
### `contacts-updated`
|
|
3073
3115
|
|
|
@@ -3106,16 +3148,26 @@ same exported checks are used by Omni and adapter tests so their interpretations
|
|
|
3106
3148
|
| --- | --- |
|
|
3107
3149
|
| `validateManifest(manifest)` | Identity, protocol-version interoperability, authentication methods, and idle-capability shapes. |
|
|
3108
3150
|
| `validateTask(task, { channel })` | Identity, channel agreement, phase, completion allowance, capability shapes, custom controls, and browsers. |
|
|
3109
|
-
| `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team roster, and every task, contact, and activity, including capability gating. |
|
|
3151
|
+
| `validateSnapshot(snapshot, manifest)` | Status, break state, break reasons, team roster, 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. |
|
|
3110
3152
|
| `validateEventEnvelope(envelope, manifest)` | Envelope identity, timestamp, and the payload for each event type. |
|
|
3111
3153
|
| `validateContact(contact)` | Contact field shapes and attribute keys. Every field is optional, so this checks what is present rather than what is missing. |
|
|
3112
3154
|
| `validateScheduledActivity(activity)` | Required activity fields and start/end ordering. |
|
|
3113
|
-
| `validateAuthenticationState(state)` | The identity each state must carry, and the expiry that only `authenticated` may. |
|
|
3155
|
+
| `validateAuthenticationState(state)` | The identity each state must carry, the capabilities a usable login declares, and the expiry that only `authenticated` may. |
|
|
3114
3156
|
|
|
3115
3157
|
Each returns `ProtocolViolation[]` rather than throwing, so a caller can report every problem at
|
|
3116
3158
|
once. A violation carries a stable `rule` id such as `task.browser.url.scheme`, the `path` it was
|
|
3117
3159
|
found at such as `snapshot.tasks[0].browsers[1].url`, and a `message`.
|
|
3118
3160
|
|
|
3161
|
+
Some rules need to know who is reading. `validateTeamRoster`, `validateSnapshot`, and
|
|
3162
|
+
`validateEventEnvelope` take an optional final `{ self, capabilities }` — the signed-in agent's
|
|
3163
|
+
`AuthenticationState.identity.id` and their login's `capabilities`. Given `self`, a roster that
|
|
3164
|
+
carries that agent reports `team.member.self` or `team.request.self`. Given `capabilities`, a lead's
|
|
3165
|
+
snapshot without a roster reports `team.required`, a roster published to a login that does not lead
|
|
3166
|
+
reports `team.unentitled`, `requests` on a roster whose login lacks `team.consultControl` reports
|
|
3167
|
+
`team.requests.capability`, and a roster without them on a login that declares it reports
|
|
3168
|
+
`team.requests.required`. Without them those rules are not checked, because they cannot be.
|
|
3169
|
+
`exerciseAdapter` always passes both.
|
|
3170
|
+
|
|
3119
3171
|
`assertNoViolations(violations)` throws `ProtocolConformanceError` — which carries the full
|
|
3120
3172
|
`violations` array — when the list is non-empty.
|
|
3121
3173
|
|
|
@@ -3131,9 +3183,10 @@ reaching the workspace.
|
|
|
3131
3183
|
Adapter conformance exercise from `@xema/omni-protocol/testing`.
|
|
3132
3184
|
|
|
3133
3185
|
It validates the manifest, opens an authenticated session, connects, checks required capability
|
|
3134
|
-
methods, subscribes, validates the snapshot
|
|
3135
|
-
|
|
3136
|
-
|
|
3186
|
+
methods, subscribes, validates the snapshot, every delivered event, and every authentication state
|
|
3187
|
+
the session publishes during the run — each against the latest login, since capabilities are
|
|
3188
|
+
current, not fixed — states a capacity, then unsubscribes and disconnects. Provider packages should
|
|
3189
|
+
run it with a deterministic test transport and authentication state.
|
|
3137
3190
|
|
|
3138
3191
|
By default it throws `ProtocolConformanceError` listing every violation. Pass
|
|
3139
3192
|
`{ collectOnly: true }` to receive them on the result instead:
|
|
@@ -3144,15 +3197,33 @@ expect(result.violations).toEqual([]);
|
|
|
3144
3197
|
expect(result.disconnectWasClean).toBe(true);
|
|
3145
3198
|
```
|
|
3146
3199
|
|
|
3147
|
-
|
|
3200
|
+
`result.authenticationState` is the state the session was restored with; `result.login` is the
|
|
3201
|
+
latest the session published during the run, which differs only when the adapter republished
|
|
3202
|
+
`authenticated`. A published state that fails validation is reported and not adopted, and a
|
|
3203
|
+
`refreshing` state must carry over the login it refreshes — a different identity is
|
|
3204
|
+
`authentication.refreshing.identity`, a changed capability set `authentication.refreshing.capabilities`.
|
|
3205
|
+
A capability granted by a later login requires its methods just as one declared at sign-in does.
|
|
3206
|
+
|
|
3207
|
+
`result.notExercised` lists what the run never reached — one subject per family of rules: each
|
|
3208
|
+
optional part of a task (`task.browsers`, `task.handlingHistory`, `task.lead`, …), the break's
|
|
3209
|
+
`reasons` and `imposed`, the roster's `members` and `requests`, each declared contribution, and
|
|
3210
|
+
each event type (`event.task-ended`, …) — and so what a clean `violations` says nothing about.
|
|
3211
|
+
Nothing there is a violation: an adapter with no team has nothing to exercise. But a fixture with
|
|
3212
|
+
no tasks exercises no task rule, and a pass over it reads as coverage it is not.
|
|
3213
|
+
`assertReached(result, subjects)` is the paired assertion: it throws naming every subject the run
|
|
3214
|
+
never met, so a test that meant to check a roster cannot pass on a fixture that never produced one.
|
|
3215
|
+
|
|
3216
|
+
Three properties of the harness matter to adapter authors:
|
|
3148
3217
|
|
|
3149
3218
|
- **Violations are collected, never thrown from inside the subscribe listener.** Throwing there
|
|
3150
3219
|
would unwind through the provider's own dispatch for a synchronous emitter, and would be
|
|
3151
3220
|
swallowed as an unhandled rejection for an asynchronous one — letting a non-conforming async
|
|
3152
3221
|
adapter pass.
|
|
3153
|
-
- **Resources are released even when the adapter fails.**
|
|
3222
|
+
- **Resources are released even when the adapter fails.** Every unsubscribe, `disconnect()`, and
|
|
3154
3223
|
`close()` run in a `finally` block, and a throw from any of them is reported as
|
|
3155
3224
|
`disconnectWasClean: false` rather than being hidden.
|
|
3225
|
+
- **The login is read, never captured.** Everything is validated against the latest
|
|
3226
|
+
`authenticated` state, so a withdrawal published before a snapshot is held against that snapshot.
|
|
3156
3227
|
|
|
3157
3228
|
### Contract scenarios
|
|
3158
3229
|
|
|
@@ -3161,7 +3232,10 @@ cannot be established from TypeScript structure alone.
|
|
|
3161
3232
|
|
|
3162
3233
|
| Helper | Contract checked |
|
|
3163
3234
|
| --- | --- |
|
|
3164
|
-
| `
|
|
3235
|
+
| `assertCapabilityWithdrawal(states, snapshot, manifest)` | A capability withdrawn by a later `authenticated` state is gone from the next snapshot: no roster for a login that no longer leads, no requests for one that may no longer join. Every state is validated on the way, `refreshing` must carry the login over, and the sequence passes only through usable states. |
|
|
3236
|
+
| `assertCommandRefusedAfterWithdrawal(result)` | A command that arrives after its capability was withdrawn fails with `omni.capability-not-enabled`, named by the provider. |
|
|
3237
|
+
| `assertReached(result, subjects)` | The exercise met every subject named; throws listing those it did not. Pair it with a clean `exerciseAdapter` result. |
|
|
3238
|
+
| `assertAuthenticationRestoreAndExpiry(states)` | A restored authenticated session can refresh and ends in expiry. Every state is validated. |
|
|
3165
3239
|
| `assertReconnectWithMissedAssignments(before, reconnect, ids)` | A reconnect snapshot restores assignments received while offline. |
|
|
3166
3240
|
| `assertDeniedAndRetriedBreak(states)` | A denial transitions directly to `not-requested`; a later request can still be granted. |
|
|
3167
3241
|
| `assertWrapTimeout(task, mediaEndedAt, deadline, toleranceMs?)` | The wrap deadline equals media end plus the task allowance, within a tolerance that defaults to 1000ms; a task with no allowance has no deadline, and one observed is the violation. |
|