@xema/omni-protocol 0.1.8 → 0.1.10

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,6 +40,17 @@ 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.
49
+
50
+ ```ts
51
+ validateSnapshot(snapshot, manifest, "snapshot", { self: authentication.identity.id });
52
+ ```
53
+
43
54
  ## Conformance
44
55
 
45
56
  `exerciseAdapter` validates the manifest, opens an authenticated session, connects, checks
package/dist/testing.js CHANGED
@@ -34,6 +34,9 @@ export async function exerciseAdapter(adapter, context, options = {}) {
34
34
  if (authenticationState.status !== "authenticated") {
35
35
  throw new Error(`Adapter contract exercise requires authenticated test state, received ${authenticationState.status}`);
36
36
  }
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 };
37
40
  connection = await adapter.connect(context);
38
41
  const live = connection;
39
42
  // The optional methods are optional only until something declares a need for them. Each
@@ -57,7 +60,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
57
60
  requireMethod("openMedia", "the manifest channel is voice");
58
61
  const eventIds = new Set();
59
62
  unsubscribe = connection.subscribe(envelope => {
60
- violations.push(...validateEventEnvelope(envelope, adapter.manifest));
63
+ violations.push(...validateEventEnvelope(envelope, adapter.manifest, "event", reader));
61
64
  if (typeof envelope?.id === "string") {
62
65
  if (eventIds.has(envelope.id))
63
66
  return;
@@ -66,7 +69,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
66
69
  events.push(envelope);
67
70
  });
68
71
  const snapshot = await connection.snapshot();
69
- violations.push(...validateSnapshot(snapshot, adapter.manifest));
72
+ violations.push(...validateSnapshot(snapshot, adapter.manifest, "snapshot", reader));
70
73
  if (snapshot?.sessionCapabilities?.breaks === true) {
71
74
  // The four stand or fall together: `granted` is a promise to honour a later commit, and
72
75
  // an adapter with requestBreak but no commitBreak leaves an agent a break that never starts.
@@ -1,4 +1,4 @@
1
- import { type ProtocolViolation } from "./index.js";
1
+ import { type ProtocolViolation, 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[];
@@ -15,7 +15,18 @@ export interface TaskValidationContext {
15
15
  channel: string;
16
16
  }
17
17
  export declare function validateTask(task: unknown, context: TaskValidationContext, path?: string): ProtocolViolation[];
18
- export declare function validateTeamRoster(roster: unknown, path?: string): ProtocolViolation[];
19
- export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string): ProtocolViolation[];
20
- export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string): ProtocolViolation[];
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.
21
+ */
22
+ export interface ReaderContext {
23
+ /**
24
+ * The signed-in agent, `AuthenticationState.identity.id`. A lead does not report to themself:
25
+ * a roster that lists them in `members`, or their own ask in `requests`, is a violation.
26
+ */
27
+ self?: UserId;
28
+ }
29
+ export declare function validateTeamRoster(roster: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
30
+ export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
31
+ export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
21
32
  export declare function validateAuthenticationState(state: unknown, path?: string): ProtocolViolation[];
@@ -705,12 +705,12 @@ function validateBreakState(value, path, into) {
705
705
  }
706
706
  });
707
707
  }
708
- export function validateTeamRoster(roster, path = "team") {
708
+ export function validateTeamRoster(roster, path = "team", context = {}) {
709
709
  const into = new Collector();
710
- validateTeamRosterInto(roster, path, into);
710
+ validateTeamRosterInto(roster, path, context, into);
711
711
  return into.violations;
712
712
  }
713
- function validateTeamRosterInto(roster, path, into) {
713
+ function validateTeamRosterInto(roster, path, context, into) {
714
714
  if (!isPlainObject(roster)) {
715
715
  into.add("team.shape", path, "a team roster must be an object");
716
716
  return;
@@ -740,7 +740,9 @@ function validateTeamRosterInto(roster, path, into) {
740
740
  into.add("team.request.unique", `${at}.id`, `duplicate request id: ${request.id}`);
741
741
  seenRequests.add(request.id);
742
742
  }
743
- into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking");
743
+ if (into.require(isUserId(request.memberId), "team.request.memberId", `${at}.memberId`, "a request names the member asking")) {
744
+ into.require(request.memberId !== context.self, "team.request.self", `${at}.memberId`, "the roster carries the reader's own ask: an agent's request for a lead goes to whoever leads them");
745
+ }
744
746
  into.require(isTaskId(request.taskId), "team.request.taskId", `${at}.taskId`, "a request names the task the lead would join");
745
747
  if (request.note !== undefined)
746
748
  into.filled(request.note, "team.request.note", `${at}.note`, "a note must not be empty when present");
@@ -763,6 +765,7 @@ function validateTeamRosterInto(roster, path, into) {
763
765
  if (seen.has(member.id))
764
766
  into.add("team.member.unique", `${at}.id`, `duplicate roster member: ${member.id}`);
765
767
  seen.add(member.id);
768
+ into.require(member.id !== context.self, "team.member.self", `${at}.id`, "the roster carries the agent it is published to: a lead does not report to themself");
766
769
  }
767
770
  into.oneOf(member.availability, TEAM_AVAILABILITIES, "team.member.availability", `${at}.availability`);
768
771
  if (member.since !== undefined)
@@ -771,7 +774,7 @@ function validateTeamRosterInto(roster, path, into) {
771
774
  into.oneOf(member.break, BREAK_APPROVALS, "team.member.break", `${at}.break`);
772
775
  });
773
776
  }
774
- export function validateSnapshot(snapshot, manifest, path = "snapshot") {
777
+ export function validateSnapshot(snapshot, manifest, path = "snapshot", context = {}) {
775
778
  const into = new Collector();
776
779
  if (!isPlainObject(snapshot)) {
777
780
  into.add("snapshot.shape", path, "a snapshot must be an object");
@@ -839,7 +842,7 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot") {
839
842
  }
840
843
  }
841
844
  if (snapshot.team !== undefined)
842
- validateTeamRosterInto(snapshot.team, `${path}.team`, into);
845
+ validateTeamRosterInto(snapshot.team, `${path}.team`, context, into);
843
846
  return into.violations;
844
847
  }
845
848
  // ---------------------------------------------------------------------------
@@ -912,7 +915,7 @@ function validateProviderSummary(value, path, into) {
912
915
  into.require(typeof metric.value === "string", "event.summary.metric.value", `${at}.value`, "a metric value must be a string; the provider decides how it reads");
913
916
  });
914
917
  }
915
- export function validateEventEnvelope(envelope, manifest, path = "event") {
918
+ export function validateEventEnvelope(envelope, manifest, path = "event", context = {}) {
916
919
  const into = new Collector();
917
920
  if (!isPlainObject(envelope)) {
918
921
  into.add("event.shape", path, "an event envelope must be an object");
@@ -931,7 +934,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event") {
931
934
  switch (event.type) {
932
935
  case "snapshot":
933
936
  into.oneOf(event.reason, SNAPSHOT_REASONS, "event.snapshot.reason", `${at}.reason`);
934
- into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`));
937
+ into.violations.push(...validateSnapshot(event.snapshot, manifest, `${at}.snapshot`, context));
935
938
  break;
936
939
  case "provider-status":
937
940
  into.oneOf(event.status, CONNECTION_STATUSES, "event.providerStatus.status", `${at}.status`);
@@ -975,7 +978,7 @@ export function validateEventEnvelope(envelope, manifest, path = "event") {
975
978
  validateProviderSummary(event.summary, `${at}.summary`, into);
976
979
  break;
977
980
  case "team-updated":
978
- validateTeamRosterInto(event.team, `${at}.team`, into);
981
+ validateTeamRosterInto(event.team, `${at}.team`, context, into);
979
982
  break;
980
983
  case "contacts-updated":
981
984
  if (!Array.isArray(event.contacts)) {
package/guide.md CHANGED
@@ -926,6 +926,11 @@ snapshot is the answer; Omni waits for it rather than calling `snapshot()` itsel
926
926
  transport is up, a result says the provider accepted the command, and the event that follows —
927
927
  `task-updated`, `break-state` — says what it did.
928
928
 
929
+ **Classify on the rejection, never on a connection status published separately from it.** The
930
+ status is a report about the wire and races the rejection; the rejection is the event. A rejection
931
+ is the provider's answer only when it carries the provider's answer — a failure the provider
932
+ named. Every other rejection is transport loss, whatever the last `provider-status` said.
933
+
929
934
  A command therefore carries no key. The provider names its own records — a task, a lead request, a
930
935
  member — and Omni refers to them by those names; **Omni never asks a provider to remember a name
931
936
  Omni made up.**
@@ -2585,6 +2590,15 @@ decides who leads a team: no roster means nothing is shown, which is the correct
2585
2590
  agent who leads nobody. The same rule governs `TeamRoster.breakControl` — present when this lead
2586
2591
  decides their team's breaks, absent when they do not.
2587
2592
 
2593
+ **The roster never carries the agent it is published to — not in `members`, and not in
2594
+ `requests`.** A lead does not report to themself: their own break request and their own ask for a
2595
+ lead go up to whoever leads them and appear on *that* person's roster, while the requester sees
2596
+ 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.
2601
+
2588
2602
  ### Lead commands
2589
2603
 
2590
2604
  One method, `executeTeamBreak`, taking a discriminated command exactly as `execute` takes a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xema/omni-protocol",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "The Omni protocol: the contract every provider adapter implements",
5
5
  "type": "module",
6
6
  "license": "MIT",