@xema/omni-protocol 0.1.106 → 0.1.108

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
@@ -11,7 +11,8 @@ system.
11
11
 
12
12
  | Path | |
13
13
  | --- | --- |
14
- | `guide.md` | **The protocol.** Rules, shapes, and the reasoning behind them. |
14
+ | `guide.md` | **The protocol.** The terms, the shapes, the rules, and how an adapter is declared, connected and held to them. |
15
+ | `guide/` | One file per role and channel: `agent.md`, `lead.md`, `breaks.md`, `phone.md`, `voice.md`, `chat.md`, `email.md`. Each points back into `guide.md` for what it relies on. |
15
16
  | `src/index.ts` | The TypeScript declarations. |
16
17
  | `src/validation.ts` | Runtime validators Omni applies to adapter output. |
17
18
  | `src/testing.ts` | Conformance helpers an adapter runs against its own test state. |
package/dist/index.d.ts CHANGED
@@ -112,6 +112,46 @@ export declare const DIAL_OUTCOMES: readonly ["answered", "busy", "no-answer", "
112
112
  */
113
113
  export type Phone = "softphone" | "deskPhone";
114
114
  export declare const PHONES: readonly ["softphone", "deskPhone"];
115
+ /**
116
+ * The device itself, as the platform sees it. `ready` is registered and usable, idle or busy; the
117
+ * other three are the states in which no call can land: the platform has lost the phone's
118
+ * registration, the agent pressed do-not-disturb on it, or left the handset off the hook with no
119
+ * call on it.
120
+ */
121
+ export type PhoneStatus = "ready" | "unregistered" | "do-not-disturb" | "off-hook";
122
+ export declare const PHONE_STATUSES: readonly ["ready", "unregistered", "do-not-disturb", "off-hook"];
123
+ export type PhoneChannelState = "ringing" | "active" | "held";
124
+ export declare const PHONE_CHANNEL_STATES: readonly ["ringing", "active", "held"];
125
+ /**
126
+ * One call on the phone, as the phone sees it: ringing, the one active, or held. A bridge is one
127
+ * channel, so a conference is one entry and its members are on the task's `onCall`. Where the call
128
+ * is a task's, `assignmentId` names it and the two views agree; absent, the call is the phone's
129
+ * alone -- an internal call on the extension, a lead's listening leg.
130
+ */
131
+ export interface PhoneChannel {
132
+ state: PhoneChannelState;
133
+ since: IsoTimestamp;
134
+ assignmentId?: AssignmentId;
135
+ }
136
+ /**
137
+ * The phone's own view, published by a provider that declares `phoneStatus`: the device the login
138
+ * is on, whether it can take a call, its own mute where the platform observes it, and its channels
139
+ * -- one active at most, any number held. The task side says which of those calls are the agent's
140
+ * work and where each stands; the phone side exists whether or not a task is behind a call. `muted`
141
+ * is the phone's own button as the platform sees it, silencing whatever is active: a desk phone's,
142
+ * never a softphone's, whose microphone is the host's and whose mute is the host's report.
143
+ */
144
+ export interface PhoneState {
145
+ /** The agent's phone for this provider, as the host chose it for the login at connect: which of the manifest's phones the agent hears this provider's calls on. */
146
+ phone: Phone;
147
+ status: PhoneStatus;
148
+ /** The phone's own mute, on a desk phone the platform observes; absent where it does not, or on a softphone. */
149
+ muted?: true;
150
+ /** When the status began, counted from the provider's clock. Omitted rather than invented. */
151
+ since?: IsoTimestamp;
152
+ /** The calls on the phone; `[]` when idle. None under `unregistered` or `off-hook`. */
153
+ channels: PhoneChannel[];
154
+ }
115
155
  /** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
116
156
  export declare const IDLE_CAPABILITIES: readonly ["dial", "personalBrowser", "calendar", "contacts"];
117
157
  export type IdleCapability = (typeof IDLE_CAPABILITIES)[number];
@@ -178,6 +218,11 @@ export interface Manifest<C extends Channel = Channel> {
178
218
  * off voice, where there is no call to hear. The host picks one of these per login.
179
219
  */
180
220
  phones?: C extends "voice" ? Phone[] : never;
221
+ /**
222
+ * The platform sees the phone itself -- its registration, its do-not-disturb, its hook -- and
223
+ * publishes it as `Snapshot.phone` and `phone-updated`. Voice only, declared by presence.
224
+ */
225
+ phoneStatus?: C extends "voice" ? true : never;
181
226
  /**
182
227
  * The provider takes running reports of a host-performed step -- `recordStep` with `seconds`
183
228
  * so far and no `ended`. Absent, the host sends exactly two reports per leg, when it began and
@@ -1290,6 +1335,8 @@ export interface TeamMember {
1290
1335
  shift?: Shift;
1291
1336
  /** The member's standing ask for a lead on one of their calls; absent when they are not asking. */
1292
1337
  request?: MemberRequest;
1338
+ /** The member's phone as the platform sees it, the same `PhoneState` the member's own snapshot carries; present where the provider declares `phoneStatus`. */
1339
+ phone?: PhoneState;
1293
1340
  }
1294
1341
  /** This member asking their lead to join a call: which assignment, the note they wrote, since when. */
1295
1342
  export interface MemberRequest {
@@ -1589,6 +1636,8 @@ export interface Snapshot<C extends Channel = Channel> {
1589
1636
  calendar?: ScheduledActivity[];
1590
1637
  /** The agent's own day so far, as the provider counts it; replaced whole by `shift-updated`. Omitted where the provider cannot say. */
1591
1638
  shift?: Shift;
1639
+ /** The phone as the platform sees it, from a provider that declares `phoneStatus`; replaced whole by `phone-updated`. */
1640
+ phone?: PhoneState;
1592
1641
  team?: TeamMembers;
1593
1642
  }
1594
1643
  /**
@@ -1705,6 +1754,11 @@ export type ProviderEvent<C extends Channel = Channel> = {
1705
1754
  | {
1706
1755
  type: "shift-updated";
1707
1756
  shift: Shift;
1757
+ }
1758
+ /** The phone as the platform now sees it, whole: a registration lost or back, do-not-disturb pressed, the handset lifted or replaced, the phone's mute. */
1759
+ | {
1760
+ type: "phone-updated";
1761
+ phone: PhoneState;
1708
1762
  };
1709
1763
  /**
1710
1764
  * The `Provider` prefix survives here for a mechanical reason rather than a naming one: `Event`
package/dist/index.js CHANGED
@@ -17,6 +17,8 @@ export function negotiateProtocolVersion(adapterVersions, hostVersions = OMNI_SU
17
17
  }
18
18
  export const DIAL_OUTCOMES = ["answered", "busy", "no-answer", "unreachable", "rejected", "cancelled", "unexplained"];
19
19
  export const PHONES = ["softphone", "deskPhone"];
20
+ export const PHONE_STATUSES = ["ready", "unregistered", "do-not-disturb", "off-hook"];
21
+ export const PHONE_CHANNEL_STATES = ["ringing", "active", "held"];
20
22
  /** Every idle capability a provider may declare. Only voice may `dial`; the channel arm says so. */
21
23
  export const IDLE_CAPABILITIES = ["dial", "personalBrowser", "calendar", "contacts"];
22
24
  /** What Omni calls each idle capability. */
package/dist/testing.d.ts CHANGED
@@ -7,7 +7,7 @@ export { ProtocolConformanceError, assertNoViolations, type ProtocolViolation }
7
7
  * rules -- each optional part of a task, each optional part of the break state and team member list, each
8
8
  * declared contribution, and each event type.
9
9
  */
10
- declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.history", "task.onCall", "task.leadAssist", "task.takenOver", "task.audio", "task.acceptance", "task.outcomes", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.forced", "team.members", "team.request", "team.tasks", "team.listening", "team.shift", "shift", "contacts", "calendar", "team.policies"];
10
+ declare const STATE_SUBJECTS: readonly ["tasks", "task.browsers", "task.attributes", "task.history", "task.onCall", "task.leadAssist", "task.takenOver", "task.audio", "task.acceptance", "task.outcomes", "task.destinations", "task.custom", "task.locked", "break.reasons", "break.forced", "team.members", "team.request", "team.tasks", "team.listening", "team.shift", "team.phone", "shift", "phone", "contacts", "calendar", "team.policies"];
11
11
  export type ContractSubject = (typeof STATE_SUBJECTS)[number] | `event.${ProviderEvent["type"]}`;
12
12
  export interface AdapterContractResult {
13
13
  events: ProviderEventEnvelope[];
package/dist/testing.js CHANGED
@@ -28,7 +28,9 @@ const STATE_SUBJECTS = [
28
28
  "team.tasks",
29
29
  "team.listening",
30
30
  "team.shift",
31
+ "team.phone",
31
32
  "shift",
33
+ "phone",
32
34
  "contacts",
33
35
  "calendar",
34
36
  "team.policies",
@@ -39,7 +41,7 @@ const EVENT_TYPES = {
39
41
  snapshot: true, "transport-status": true, "break-state": true, "task-offered": true, "task-updated": true,
40
42
  "task-audio-started": true, "task-audio-ended": true, "task-ended": true, "dial-outcome": true, announcement: true, "queue-summary": true, diagnostic: true,
41
43
  "team-updated": true, "team-member-updated": true, "team-member-removed": true, "team-policies-updated": true,
42
- "contacts-updated": true, "calendar-updated": true, "shift-updated": true,
44
+ "contacts-updated": true, "calendar-updated": true, "shift-updated": true, "phone-updated": true,
43
45
  };
44
46
  const CONTRACT_SUBJECTS = [
45
47
  ...STATE_SUBJECTS,
@@ -101,6 +103,8 @@ function observeMember(member, seen) {
101
103
  seen.add("team.shift");
102
104
  if (member.request !== undefined)
103
105
  seen.add("team.request");
106
+ if (member.phone !== undefined)
107
+ seen.add("team.phone");
104
108
  }
105
109
  function observePolicies(value, seen) {
106
110
  if (isRecord(value) && Object.keys(value).length > 0)
@@ -129,6 +133,8 @@ function observeSnapshot(value, seen) {
129
133
  seen.add("calendar");
130
134
  if (value.shift !== undefined)
131
135
  seen.add("shift");
136
+ if (value.phone !== undefined)
137
+ seen.add("phone");
132
138
  }
133
139
  function observeEvent(envelope, seen) {
134
140
  const event = isRecord(envelope) ? envelope.event : undefined;
@@ -168,6 +174,9 @@ function observeEvent(envelope, seen) {
168
174
  case "shift-updated":
169
175
  seen.add("shift");
170
176
  break;
177
+ case "phone-updated":
178
+ seen.add("phone");
179
+ break;
171
180
  default: break;
172
181
  }
173
182
  }
@@ -237,6 +246,7 @@ export async function exerciseAdapter(adapter, context, options = {}) {
237
246
  self: current().identity.id,
238
247
  capabilities: current().capabilities,
239
248
  leadFeatures: leadFeaturesOn,
249
+ phone: context.phone,
240
250
  loginId: context.loginId,
241
251
  autoAcceptTasks: context.autoAcceptTasks,
242
252
  locked: options.lockedValues,
@@ -1074,7 +1084,7 @@ const REACHABLE_PHASES = {
1074
1084
  export const SUPERSEDED_BY_A_SNAPSHOT = new Set([
1075
1085
  "snapshot", "transport-status", "break-state", "task-offered", "task-updated", "task-ended",
1076
1086
  "task-audio-started", "task-audio-ended", "team-updated", "team-member-updated", "team-member-removed", "team-policies-updated",
1077
- "contacts-updated", "calendar-updated", "shift-updated",
1087
+ "contacts-updated", "calendar-updated", "shift-updated", "phone-updated",
1078
1088
  ]);
1079
1089
  export class TaskStream {
1080
1090
  tasks = new Map();
@@ -1,4 +1,4 @@
1
- import { type RecordingState, type Channel, type ProtocolViolation, type UserCapabilities, type UserId } from "./index.js";
1
+ import { type RecordingState, type Channel, type Phone, type ProtocolViolation, type UserCapabilities, 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[];
@@ -99,6 +99,11 @@ export interface ReaderContext {
99
99
  levels?: readonly string[];
100
100
  /** The login's `loginId`. A snapshot or event naming another belongs to a login that is gone. */
101
101
  loginId?: string;
102
+ /**
103
+ * The phone the host chose for this login at connect, `ConnectContext.phone`. A phone state
104
+ * naming another is the wrong phone. Unknown to a caller without the context, and then unchecked.
105
+ */
106
+ phone?: Phone;
102
107
  /**
103
108
  * Whether the lead has the team feature on, as the application last told the provider. The
104
109
  * provider assumes nothing until told: on, a snapshot carries the team; off, nothing of the
@@ -121,6 +126,8 @@ export declare function validateTeamMembers(teamMembers: unknown, path?: string,
121
126
  export declare function validateTeamMember(member: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
122
127
  export declare function validateSnapshot(snapshot: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
123
128
  export declare function validateEventEnvelope(envelope: unknown, manifest: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
129
+ /** The phone as the platform sees it: the agent's device for this provider, whether it can take a call, its own mute where observed. */
130
+ export declare function validatePhoneState(value: unknown, path?: string, context?: ReaderContext): ProtocolViolation[];
124
131
  /**
125
132
  * What pressing Mute does on this host, stated on a softphone login and nowhere else: on a desk
126
133
  * phone the microphone is the phone's, and off voice there is none. Neither the value nor its
@@ -87,6 +87,8 @@ const OFFERABLE_PHASES = membersOf({
87
87
  });
88
88
  const PREVIEW_DEADLINES = membersOf({ "provider-dials": true, "host-dials": true, waits: true });
89
89
  const PHONES = membersOf({ softphone: true, deskPhone: true });
90
+ const PHONE_STATUSES = membersOf({ ready: true, unregistered: true, "do-not-disturb": true, "off-hook": true });
91
+ const PHONE_CHANNEL_STATES = membersOf({ ringing: true, active: true, held: true });
90
92
  const LISTENING_MODES = membersOf({ listen: true, coach: true, "join-call": true });
91
93
  const COMPLETED_BY = membersOf({ agent: true, provider: true });
92
94
  const CANCELLED_BY = membersOf({ agent: true, provider: true, party: true });
@@ -369,6 +371,10 @@ function validatePhones(manifest, path, into) {
369
371
  into.add("manifest.phone.unique", `${path}[${index}]`, `duplicate phone: ${describeValue(phone)}`);
370
372
  }
371
373
  });
374
+ // Seeing the phone itself is declared by presence, and only where there is a phone to see.
375
+ if (manifest.phoneStatus !== undefined) {
376
+ into.require(manifest.phoneStatus === true, "manifest.phoneStatus", `${path.replace(/phones$/, "phoneStatus")}`, "phoneStatus is declared by presence, as true; a platform that cannot see the phone omits it");
377
+ }
372
378
  }
373
379
  /**
374
380
  * The host's choice of phone for a login, held to the manifest: one the platform listed, required
@@ -1742,6 +1748,9 @@ function validateTeamMemberInto(member, at, context, into) {
1742
1748
  // The member's own history for the day: about the person, not any one call.
1743
1749
  if (member.shift !== undefined)
1744
1750
  validateShiftInto(member.shift, `${at}.shift`, into, "team.member.shift");
1751
+ // The member's phone as the platform sees it, the same state the member's own desk holds.
1752
+ if (member.phone !== undefined)
1753
+ validatePhoneStateInto(member.phone, `${at}.phone`, {}, into);
1745
1754
  // The member's standing ask for a lead, on one of the calls they hold.
1746
1755
  if (member.request !== undefined) {
1747
1756
  if (!isPlainObject(member.request)) {
@@ -1779,6 +1788,39 @@ export function validateSnapshot(snapshot, manifest, path = "snapshot", context
1779
1788
  // The agent's own day, as the provider counts it: the same numbers their lead sees.
1780
1789
  if (snapshot.shift !== undefined)
1781
1790
  validateShiftInto(snapshot.shift, `${path}.shift`, into, "shift");
1791
+ // The phone as the platform sees it: owed by a provider that declares it can, and by nobody else.
1792
+ const seesPhone = isPlainObject(manifest) && manifest.phoneStatus === true;
1793
+ if (seesPhone && snapshot.phone === undefined) {
1794
+ into.add("snapshot.phone.required", `${path}.phone`, "the manifest declares phoneStatus, so every snapshot carries the phone as the platform sees it");
1795
+ }
1796
+ else if (!seesPhone && snapshot.phone !== undefined && isPlainObject(manifest)) {
1797
+ into.add("snapshot.phone.unexpected", `${path}.phone`, "the manifest declares no phoneStatus: a phone the platform never said it could see is one it invented");
1798
+ }
1799
+ if (snapshot.phone !== undefined) {
1800
+ validatePhoneStateInto(snapshot.phone, `${path}.phone`, context, into);
1801
+ // The two views of one call agree: a channel that is a task's stands where the task does.
1802
+ if (isPlainObject(snapshot.phone) && Array.isArray(snapshot.phone.channels) && Array.isArray(snapshot.tasks)) {
1803
+ const tasks = snapshot.tasks.filter(isPlainObject);
1804
+ snapshot.phone.channels.forEach((channel, index) => {
1805
+ if (!isPlainObject(channel) || !isAssignmentId(channel.assignmentId))
1806
+ return;
1807
+ const here = `${path}.phone.channels[${index}]`;
1808
+ const task = tasks.find(task => task.assignmentId === channel.assignmentId);
1809
+ if (!into.require(task !== undefined, "phone.channel.assignment", `${here}.assignmentId`, `${String(channel.assignmentId)} is not a task this login holds: a channel names the task its call is, or nothing`))
1810
+ return;
1811
+ const agrees = channel.state === "active" ? task.phase === "in-progress" && task.audio === "started"
1812
+ : channel.state === "held" ? task.phase === "paused"
1813
+ : task.phase === "pending" || (Array.isArray(task.onCall) && task.onCall.some((who) => isPlainObject(who) && who.role === "party" && who.stage === "ringing"));
1814
+ into.require(agrees, "phone.channel.task", `${here}.state`, `the phone says ${String(channel.state)} and the task ${String(channel.assignmentId)} says ${String(task.phase)}${task.audio === undefined ? "" : ` with audio ${String(task.audio)}`}: the two views of one call agree`);
1815
+ });
1816
+ // And a task at work with audio has a channel: the phone carries every call the desk is on.
1817
+ tasks.forEach((task, index) => {
1818
+ if (task.audio === "started" && !snapshot.phone.channels.some(channel => isPlainObject(channel) && channel.assignmentId === task.assignmentId)) {
1819
+ into.add("phone.channel.missing", `${path}.tasks[${index}]`, `${String(task.assignmentId)} has its audio started and the phone carries no channel for it`);
1820
+ }
1821
+ });
1822
+ }
1823
+ }
1782
1824
  if (into.filled(snapshot.loginId, "snapshot.loginId", `${path}.loginId`, "a snapshot needs the login id it belongs to")
1783
1825
  && context.loginId !== undefined) {
1784
1826
  into.require(snapshot.loginId === context.loginId, "snapshot.loginId.mismatch", `${path}.loginId`, `a snapshot for session ${describeValue(snapshot.loginId)} on a login whose session is ${context.loginId}`);
@@ -2065,6 +2107,10 @@ export function validateEventEnvelope(envelope, manifest, path = "event", contex
2065
2107
  event.contacts.forEach((contact, index) => validateContactInto(contact, `${at}.contacts[${index}]`, into, levels));
2066
2108
  }
2067
2109
  break;
2110
+ case "phone-updated":
2111
+ into.require(isPlainObject(manifest) && manifest.phoneStatus === true, "event.phone.capability", `${at}.phone`, "phone-updated comes from a provider whose manifest declares phoneStatus");
2112
+ validatePhoneStateInto(event.phone, `${at}.phone`, context, into);
2113
+ break;
2068
2114
  case "shift-updated":
2069
2115
  validateShiftInto(event.shift, `${at}.shift`, into, "shift");
2070
2116
  break;
@@ -2146,6 +2192,54 @@ function requireClockInto(value, manifest, path, into) {
2146
2192
  return;
2147
2193
  into.add("manifest.timeCheck.required", path, "this publishes an instant the desk renders as a running duration -- a since, a signedInAt -- and the manifest declares no timeCheck: every screen counts such a duration from the provider's clock, so the provider states one");
2148
2194
  }
2195
+ /** The phone as the platform sees it: the agent's device for this provider, whether it can take a call, its own mute where observed. */
2196
+ export function validatePhoneState(value, path = "phone", context = {}) {
2197
+ const into = new Collector();
2198
+ validatePhoneStateInto(value, path, context, into);
2199
+ return into.violations;
2200
+ }
2201
+ function validatePhoneStateInto(value, path, context, into) {
2202
+ if (!isPlainObject(value)) {
2203
+ into.add("phone.shape", path, "the phone state must be an object");
2204
+ return;
2205
+ }
2206
+ if (into.oneOf(value.phone, PHONES, "phone.phone", `${path}.phone`) && context.phone !== undefined) {
2207
+ into.require(value.phone === context.phone, "phone.phone.mismatch", `${path}.phone`, `the host chose a ${context.phone} for this login at connect and the platform reports a ${describeValue(value.phone)}: the phone state is the phone the agent hears this provider's calls on`);
2208
+ }
2209
+ into.oneOf(value.status, PHONE_STATUSES, "phone.status", `${path}.status`);
2210
+ if (value.muted !== undefined) {
2211
+ into.require(value.muted === true, "phone.muted", `${path}.muted`, "the phone's mute is stated by presence: send true or omit it");
2212
+ // The softphone's microphone is the host's, and its mute is the host's report; the platform observes a desk phone's button, or nothing.
2213
+ into.require(value.phone !== "softphone", "phone.muted.softphone", `${path}.muted`, "a softphone's microphone is the host's and its mute is the host's report; the platform echoes nothing of it");
2214
+ }
2215
+ if (value.since !== undefined)
2216
+ into.timestamp(value.since, "phone.since", `${path}.since`);
2217
+ // The phone's own calls: one active at most, any number held, and none on a phone that cannot carry one.
2218
+ if (!Array.isArray(value.channels)) {
2219
+ into.add("phone.channels.shape", `${path}.channels`, "channels is the phone's calls, an array: [] when idle");
2220
+ }
2221
+ else {
2222
+ let active = 0;
2223
+ value.channels.forEach((channel, index) => {
2224
+ const here = `${path}.channels[${index}]`;
2225
+ if (!isPlainObject(channel)) {
2226
+ into.add("phone.channel.shape", here, "each channel must be an object");
2227
+ return;
2228
+ }
2229
+ if (into.oneOf(channel.state, PHONE_CHANNEL_STATES, "phone.channel.state", `${here}.state`) && channel.state === "active")
2230
+ active += 1;
2231
+ into.timestamp(channel.since, "phone.channel.since", `${here}.since`);
2232
+ if (channel.assignmentId !== undefined)
2233
+ into.require(isAssignmentId(channel.assignmentId), "phone.channel.assignmentId", `${here}.assignmentId`, "assignmentId must name an assignment when present");
2234
+ for (const key of Object.keys(channel))
2235
+ into.require(["state", "since", "assignmentId"].includes(key), "phone.channel.field", `${here}.${key}`, "unsupported channel field");
2236
+ });
2237
+ into.require(active <= 1, "phone.channel.active.single", `${path}.channels`, `${active} channels are active: the phone has one active audio channel, and every other call on it is held`);
2238
+ into.require(!((value.status === "unregistered" || value.status === "off-hook") && value.channels.length > 0), "phone.status.channels", `${path}.channels`, `a phone that is ${String(value.status)} carries no call`);
2239
+ }
2240
+ for (const key of Object.keys(value))
2241
+ into.require(["phone", "status", "muted", "since", "channels"].includes(key), "phone.field", `${path}.${key}`, "unsupported phone state field");
2242
+ }
2149
2243
  function validateShiftInto(value, path, into, rule) {
2150
2244
  if (!isPlainObject(value)) {
2151
2245
  into.add(`${rule}.shape`, path, "shift must be an object when present");