@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 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
- One rule needs more than the object in hand. A roster never carries the agent it is published to
44
- a lead does not report to themself — and a validator cannot know who that is from the roster
45
- alone. `validateTeamRoster`, `validateSnapshot`, and `validateEventEnvelope` each take an optional
46
- final `{ self }`, the signed-in agent's `AuthenticationState.identity.id`; given it, they report
47
- `team.member.self` and `team.request.self`. Without it that rule is not checked. `exerciseAdapter`
48
- always passes it.
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
- validateSnapshot(snapshot, manifest, "snapshot", { self: authentication.identity.id });
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 and every delivered event, states
58
- a capacity, then unsubscribes and disconnects.
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, reconnect with missed
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
- * Only `authenticated` and `refreshing` know who the agent is, and only `authenticated` has
140
- * something to expire. A state carrying more than it knows is a state Omni would render as fact.
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
- breakControl?: true;
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 `sessionCapabilities.breaks` and
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 adapter publishes a `TeamRoster` carrying `breakControl`. */
913
+ /** Required when the login declares `capabilities.team.breakControl`. */
900
914
  executeTeamBreak?(request: TeamBreakCommandRequest): Promise<TeamCommandResult>;
901
- /** Required when the adapter publishes a `TeamRoster` carrying `consultControl`. */
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
- /** True only when `disconnect()` and `close()` both settled without throwing. */
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 and every delivered event, states a capacity, then unsubscribes and
20
- * disconnects.
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
- /** Validates restored authentication followed by a refresh failure or expiry. */
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 and every delivered event, states a capacity, then unsubscribes and
8
- * disconnects.
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 adapter to a rule the structural
38
- // validators cannot check alone: a roster never carries the agent it is published to.
39
- const reader = { self: authenticationState.identity.id };
40
- connection = await adapter.connect(context);
41
- const live = connection;
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 requireMethod = (name, because) => {
46
- if (typeof live[name] !== "function") {
47
- violations.push({
48
- rule: `connection.${name}.required`,
49
- path: `connection.${name}`,
50
- message: `${because}, but the connection does not implement ${name}()`,
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
- violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader));
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
- violations.push(...validateSnapshot(snapshot, adapter.manifest, "snapshot", reader));
73
- if (snapshot?.sessionCapabilities?.breaks === true) {
74
- // The four stand or fall together: `granted` is a promise to honour a later commit, and
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(), disconnect(), or close() threw during shutdown",
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
- /** Validates restored authentication followed by a refresh failure or expiry. */
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();
@@ -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 structure without it; given
20
- * `self`, they also check that a roster never carries the agent it is published to.
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[];
@@ -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, teamBreakControl: 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
- if (roster.breakControl !== undefined) {
719
- into.require(roster.breakControl === true, "team.breakControl", `${path}.breakControl`, "breakControl is declared by presence: send true or omit it");
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.consultControl !== undefined) {
722
- into.require(roster.consultControl === true, "team.consultControl", `${path}.consultControl`, "consultControl is declared by presence: send true or omit it");
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
- if (roster.requests !== undefined) {
731
+ else {
725
732
  // Requests are what a lead acts on, so a lead who may not act has no business receiving them.
726
- into.require(roster.consultControl === true, "team.requests.capability", `${path}.requests`, "requests require consultControl: a lead who may not join has nothing to decide");
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 the provider also publishes a `TeamRoster` to. Nothing else makes somebody a lead: **its presence is the permission**. |
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 and expiry changes
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 remains available. |
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
- If authentication expires during active work, Omni preserves the task workspace and shows
1328
- reauthentication for that provider.
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
- `cancelled`, and a repeat is safe and answers `already-cancelled`.
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 an authenticated provider identity or a typed failure:
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(requestId)` revokes or invalidates the provider session where supported, deletes stored
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
- `sessionCapabilities` declares provider actions available for the current login rather than for one
1466
- task. Protocol v1 includes agent break requests and team break control. A session action is
1467
- available only when both the corresponding session capability and Omni provisioning permit it.
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` for an agent who leads a team. Omitted for everybody elseits presence is the permission. |
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)` | `sessionCapabilities.breaks` is declared. |
1497
- | `commitBreak()` | `sessionCapabilities.breaks` is declared. Commit and cancel are not optional halves of it. |
1498
- | `cancelBreak()` | `sessionCapabilities.breaks` is declared. |
1499
- | `endBreak()` | `sessionCapabilities.breaks` is declared. |
1500
- | `executeTeamBreak(command)` | The adapter publishes a `TeamRoster` carrying `breakControl`. |
1501
- | `executeTeamConsult(command)` | The adapter publishes a `TeamRoster` carrying `consultControl`. |
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 `sessionCapabilities.breaks` and then
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 `sessionCapabilities.breaks`. If one cannot be stopped, offering a global break would
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 **Its presence is the permission** below. |
2557
- | `breakControl` | Present when this lead decides their team's breaks, absent when they do not. |
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
- **Its presence is the permission.** Publish a roster only to an agent entitled to one. Omni never
2589
- decides who leads a team: no roster means nothing is shown, which is the correct rendering for an
2590
- agent who leads nobody. The same rule governs `TeamRoster.breakControl` present when this lead
2591
- decides their team's breaks, absent when they do not.
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. **Entitlement is a
2598
- role the provider knows, never inferred from who is listed:** a lead with nobody in their team
2599
- publishes `[]`, an agent with no such role publishes nothing, and no member count can tell those
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 roster carries `consultControl`, and gated by it exactly as `executeTeamBreak`
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 whoever is reading a roster that carries `breakControl` — which is how the provider
2722
- already says who may decide — and does not try to work out whose turn it is.
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 manifest did not declare. |
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, session capabilities, break state, tasks, contacts, scheduled activities and team
2971
- roster with this snapshot. A roster absent from the snapshot withdraws one previously published,
2972
- exactly as it would withdraw a capability.
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**. Omitting the roster on a later snapshot withdraws it.
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 and every delivered event, states a capacity, then
3135
- unsubscribes and disconnects. Provider packages should run it with a deterministic
3136
- test transport and authentication state.
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
- Two properties of the harness matter to adapter authors:
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.** `unsubscribe()`, `disconnect()`, and
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
- | `assertAuthenticationRestoreAndExpiry(states)` | A restored authenticated session can refresh and ends in expiry. |
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. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",