@deepwatch/dsh-contracts 0.1.0

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.
@@ -0,0 +1,333 @@
1
+ /**
2
+ * What a person actually chose: which provider, which model, for which role.
3
+ *
4
+ * {@link module:@deepwatch/dsh-contracts/readiness} answers "can this run?"
5
+ * from four separate facts. This module is where three of those facts are
6
+ * *kept* — a durable document recording the decisions, so that reopening the
7
+ * product finds the same bindings rather than an empty screen and a composer
8
+ * pointed at somebody else's default.
9
+ *
10
+ * **A binding is a reference, never a credential.** The document below can be
11
+ * read by anything: it is written to the Harness's own settings file, it rides
12
+ * the settings RPC, it appears in Diagnostics, and it is included in a session
13
+ * export. None of that is safe unless the rule is absolute, so it is: the only
14
+ * credential-shaped field here is {@link RoleBindingRecord.credentialRef}, an
15
+ * opaque handle the Host resolves against its own store. No value, no prefix,
16
+ * no suffix, no length, no hash. {@link assertNoSecretMaterial} is the test
17
+ * this file is held to.
18
+ *
19
+ * **Nothing is bound implicitly.** There is no "default role", no inheritance
20
+ * from one role to another, and no provider that becomes bound because it was
21
+ * the only one configured. A role with no entry in {@link WatchBindings.roles}
22
+ * is unbound, and unbound means the composer refuses. That is the whole point
23
+ * of the module: the failure it exists to prevent was a product that treated a
24
+ * saved credential as a decision the person never made.
25
+ *
26
+ * @module @deepwatch/dsh-contracts/bindings
27
+ */
28
+ /**
29
+ * The settings namespace this document lives in.
30
+ *
31
+ * A DeepWatch-owned section of the Harness's own user-settings document, which
32
+ * is what makes the binding durable, hot-reloaded and editable by hand without
33
+ * DeepWatch inventing a second configuration store beside the one the product
34
+ * already has.
35
+ */
36
+ export const BINDINGS_NAMESPACE = 'watch-bindings';
37
+ /**
38
+ * The document revision this build writes.
39
+ *
40
+ * Read forward, never rewritten in place: an older document is migrated on
41
+ * read and a newer one is refused rather than silently reinterpreted, because
42
+ * misreading a binding routes somebody's prompt somewhere they did not choose.
43
+ */
44
+ export const BINDINGS_VERSION = 1;
45
+ /**
46
+ * The roles a person can bind to a provider, in the order setup presents them.
47
+ *
48
+ * These ids are `RoleId`s from `@deepwatch/dsh-technology`, spelled here rather
49
+ * than imported because `contracts` is the package everything else depends on
50
+ * and must not depend on anything. `tests/bindings-store.test.mjs` asserts the
51
+ * two lists agree, so the duplication is checked rather than trusted -- one
52
+ * role vocabulary with a gate on it, instead of two that drift.
53
+ *
54
+ * It is a *subset*. `verifier`, `ocr_layout`, `reranking` and
55
+ * `speaker_diarization` are served by local engines rather than chosen from a
56
+ * provider catalogue, so offering them here would offer a choice that is not
57
+ * there.
58
+ *
59
+ * `agent_model` is first and is the only one the first conversation needs. The
60
+ * rest are progressive: a product that demanded five bindings before the first
61
+ * message would be a product nobody finished configuring.
62
+ */
63
+ export const BINDABLE_ROLES = [
64
+ 'agent_model', 'visual_perception', 'asr', 'audio_understanding', 'embeddings',
65
+ ];
66
+ /** The role the first conversation needs, named once so nothing spells it twice. */
67
+ export const PRIMARY_ROLE = 'agent_model';
68
+ /**
69
+ * What each role is called on screen.
70
+ *
71
+ * `agent_model` is labelled **Chat**, and the difference is not cosmetic. A
72
+ * person configuring this product is not choosing an "agent model" -- they are
73
+ * choosing what answers them in the surface the Harness calls Chat, and every
74
+ * blocked-composer message and setup step has to name the thing they are
75
+ * looking at. The id stays `agent_model` because that is the vocabulary the
76
+ * descriptors and the routing rules already use.
77
+ */
78
+ export const ROLE_LABEL = {
79
+ agent_model: 'Chat',
80
+ visual_perception: 'Visual perception',
81
+ asr: 'Speech to text',
82
+ audio_understanding: 'Audio understanding',
83
+ embeddings: 'Embeddings and retrieval',
84
+ };
85
+ /** Whether a string is a role this product binds. */
86
+ export function isBindableRole(value) {
87
+ return BINDABLE_ROLES.includes(value);
88
+ }
89
+ /**
90
+ * What each role is for, in a person's words.
91
+ *
92
+ * Here rather than in a component because the setup flow, the Role Bindings
93
+ * screen and the blocked-composer card all name the same role, and three
94
+ * copies of this sentence would eventually be three different sentences.
95
+ */
96
+ export const ROLE_PURPOSE = {
97
+ agent_model: 'Plans, reasons and writes. This is what answers you in a conversation.',
98
+ visual_perception: 'Reads what is on screen or in a frame.',
99
+ asr: 'Speech to text, with timings a citation can point at.',
100
+ audio_understanding: 'Non-speech audio: events, tone, music.',
101
+ embeddings: 'Search over the library and over memory.',
102
+ };
103
+ /** The modalities each role's work actually needs a route to support. */
104
+ export const ROLE_MODALITIES = {
105
+ agent_model: ['text'],
106
+ visual_perception: ['vision'],
107
+ asr: ['audio'],
108
+ audio_understanding: ['audio'],
109
+ embeddings: ['embedding'],
110
+ };
111
+ /** The actors a stored document may name. */
112
+ export const BINDING_ACTORS = ['person', 'setup', 'unknown'];
113
+ /** Whether a stored value is an actor this build understands. */
114
+ export function isBindingActor(value) {
115
+ return typeof value === 'string' && BINDING_ACTORS.includes(value);
116
+ }
117
+ /** The document a profile that has never been configured has. */
118
+ export const EMPTY_BINDINGS = { version: BINDINGS_VERSION, roles: {} };
119
+ /**
120
+ * A model id worth storing.
121
+ *
122
+ * Deliberately permissive about shape — provider model ids are provider-owned
123
+ * and this product does not get to decide that `openai/gpt-4o` is malformed —
124
+ * and deliberately strict about the things that make a stored value dangerous:
125
+ * control characters, newlines and absurd length, all of which arrive from a
126
+ * hand-edited settings file rather than from the picker.
127
+ */
128
+ export function isStorableId(value) {
129
+ return typeof value === 'string'
130
+ && value !== ''
131
+ && value.length <= 200
132
+ // eslint-disable-next-line no-control-regex -- rejecting these is the point.
133
+ && !/[\u0000-\u001f\u007f]/.test(value);
134
+ }
135
+ /**
136
+ * Read a stored document, keeping only what is well-formed.
137
+ *
138
+ * A hand-edited settings file is a supported way to configure this product, so
139
+ * a malformed entry must not take the whole document with it: the bad role is
140
+ * dropped and the rest survive. Dropping is the safe direction — an unbound
141
+ * role refuses at the composer, where a person is told what to fix, whereas a
142
+ * half-read binding would route a prompt somewhere nobody chose.
143
+ *
144
+ * @param raw - whatever the settings document held.
145
+ * @returns a document this build can act on.
146
+ */
147
+ export function readBindings(raw) {
148
+ if (typeof raw !== 'object' || raw === null)
149
+ return EMPTY_BINDINGS;
150
+ const record = raw;
151
+ // A document from a future build is not merged, not guessed at, and not
152
+ // partially honoured: the shape it uses is one this build has never seen.
153
+ if (typeof record.version === 'number' && record.version > BINDINGS_VERSION) {
154
+ return EMPTY_BINDINGS;
155
+ }
156
+ if (typeof record.roles !== 'object' || record.roles === null)
157
+ return EMPTY_BINDINGS;
158
+ const roles = {};
159
+ for (const [role, value] of Object.entries(record.roles)) {
160
+ if (!isBindableRole(role))
161
+ continue;
162
+ const entry = readBindingRecord(value);
163
+ if (entry !== null)
164
+ roles[role] = entry;
165
+ }
166
+ return { version: BINDINGS_VERSION, roles };
167
+ }
168
+ /** One entry, or null when it is not something this build can honour. */
169
+ function readBindingRecord(value) {
170
+ if (typeof value !== 'object' || value === null)
171
+ return null;
172
+ const entry = value;
173
+ if (!isStorableId(entry['provider']) || !isStorableId(entry['model']))
174
+ return null;
175
+ const ref = entry['credentialRef'];
176
+ // A non-string reference is read as "no reference" rather than refused: the
177
+ // binding's provider and model are still the person's choice, and the Host
178
+ // reports an unresolvable credential as a blocker they can act on.
179
+ const credentialRef = typeof ref === 'string' && ref !== '' ? ref : null;
180
+ const boundAt = typeof entry['boundAt'] === 'string' ? entry['boundAt'] : '';
181
+ // Anything this build does not recognise reads as `unknown`, including the
182
+ // absence of the field. A document from an earlier version is unattributed,
183
+ // which is true, rather than attributed to a person who may not have written
184
+ // it.
185
+ const boundBy = isBindingActor(entry['boundBy']) ? entry['boundBy'] : 'unknown';
186
+ return {
187
+ provider: entry['provider'], model: entry['model'], credentialRef, boundAt, boundBy,
188
+ };
189
+ }
190
+ /**
191
+ * The document with one role bound, as a new value.
192
+ *
193
+ * Never mutates: the caller holds a snapshot it may still be rendering from,
194
+ * and a document edited underneath a React tree is a stale-render bug that
195
+ * shows somebody the binding they had a moment ago.
196
+ */
197
+ export function withBinding(current, role, record) {
198
+ return { version: BINDINGS_VERSION, roles: { ...current.roles, [role]: record } };
199
+ }
200
+ /** The document with one role unbound. */
201
+ export function withoutBinding(current, role) {
202
+ const roles = Object.fromEntries(Object.entries(current.roles).filter(([key]) => key !== role));
203
+ return { version: BINDINGS_VERSION, roles };
204
+ }
205
+ /**
206
+ * The readiness-shaped view of one stored role, or null when it is unbound.
207
+ *
208
+ * The join between this module and `readiness`: storage keeps records, the
209
+ * gate takes {@link RoleBinding}s, and this is the only place that converts
210
+ * one into the other — so the modalities a role is checked against always come
211
+ * from {@link ROLE_MODALITIES} rather than from whatever a call site guessed.
212
+ */
213
+ export function bindingFor(bindings, role) {
214
+ const record = bindings.roles[role];
215
+ if (record === undefined)
216
+ return null;
217
+ return {
218
+ role,
219
+ provider: record.provider,
220
+ model: record.model,
221
+ credentialRef: record.credentialRef,
222
+ modalities: ROLE_MODALITIES[role],
223
+ };
224
+ }
225
+ /** Whether a role has a stored decision at all. Not whether it can run. */
226
+ export function isBound(bindings, role) {
227
+ return bindings.roles[role] !== undefined;
228
+ }
229
+ /**
230
+ * Every provider a stored binding names, once each.
231
+ *
232
+ * What Settings uses to decide which providers to show credential state for:
233
+ * the ones a person actually pointed something at, rather than all
234
+ * thirty-seven routes the catalogue carries.
235
+ */
236
+ export function boundProviders(bindings) {
237
+ const seen = new Set();
238
+ for (const record of Object.values(bindings.roles))
239
+ seen.add(record.provider);
240
+ return [...seen].sort();
241
+ }
242
+ /**
243
+ * Whether a provider/model pair is one this profile actually bound.
244
+ *
245
+ * The authoritative question, and deliberately the *narrow* one. It does not
246
+ * ask whether the route exists, whether a credential is stored, or whether the
247
+ * provider is reachable — those are the Host's to answer at the moment of the
248
+ * request. It asks the only thing a stored document can answer: did somebody
249
+ * choose this pair for something.
250
+ *
251
+ * That is what makes it usable as a gate at a routing boundary. A request for
252
+ * a pair nobody bound is a request nobody authorised, whatever the client that
253
+ * produced it believed — a stale tab holding a selection that has since been
254
+ * changed, or a caller that set one directly and skipped the screens.
255
+ *
256
+ * Any bound role counts, not only the one being served: a person who bound
257
+ * OpenRouter to Chat has authorised that route, and the title and compaction
258
+ * calls that ride the same selection are the same authorisation, not new ones.
259
+ *
260
+ * @param bindings - the stored document.
261
+ * @param provider - the route the request names.
262
+ * @param model - the model the request names.
263
+ * @returns whether some role in this profile is bound to exactly that pair.
264
+ */
265
+ export function isRoutePermitted(bindings, provider, model) {
266
+ if (provider === '' || model === '')
267
+ return false;
268
+ return Object.values(bindings.roles).some(record => record.provider === provider && record.model === model);
269
+ }
270
+ /**
271
+ * Every distinct provider/model pair this profile bound, for a diagnostic.
272
+ *
273
+ * A refusal that says "this route is not bound" is not much use without the
274
+ * ability to say what *is*, and that list is a set of choices rather than
275
+ * anything sensitive — no credential, no reference, no host path.
276
+ */
277
+ export function permittedRoutes(bindings) {
278
+ const seen = new Set();
279
+ for (const record of Object.values(bindings.roles)) {
280
+ seen.add(`${record.provider}/${record.model}`);
281
+ }
282
+ return [...seen].sort();
283
+ }
284
+ /**
285
+ * Patterns that must never appear in a stored binding.
286
+ *
287
+ * Not an exhaustive secret detector — there is no such thing — but a guard on
288
+ * the shapes a credential takes when somebody pastes one into a field meant to
289
+ * hold a reference.
290
+ *
291
+ * `OPENROUTER_API_KEY` is deliberately *not* one of them, and that distinction
292
+ * is the design rather than an omission: it is a reference — the name of a
293
+ * place the Host looks — and naming a place is exactly what `credentialRef` is
294
+ * for. A guard that refused it would refuse every legitimate binding, be
295
+ * switched off within a day, and stop catching what it was written for. What
296
+ * is refused is a *value*: the shapes a key has when the key itself is pasted.
297
+ */
298
+ const SECRET_SHAPES = [
299
+ /\bsk-[A-Za-z0-9_-]{8,}/,
300
+ /\bsk_live_[A-Za-z0-9]{8,}/,
301
+ /\bBearer\s+[A-Za-z0-9._-]{8,}/i,
302
+ // A long run of mixed-case token characters. A reference is a name a person
303
+ // could read aloud; anything this long that is not one is a value somebody
304
+ // pasted where a name belongs.
305
+ /(?=[A-Za-z0-9+/_-]{32,})(?=[^\n]*[a-z])(?=[^\n]*[A-Z])(?=[^\n]*\d)[A-Za-z0-9+/_-]{32,}/,
306
+ ];
307
+ /**
308
+ * Throw when a document about to be stored or shown carries secret material.
309
+ *
310
+ * A programming error rather than a runtime condition: every write path builds
311
+ * this document from a picker, so a value matching one of these means a code
312
+ * path has started copying a credential into a place that is read back in
313
+ * plain text. Failing loudly at the write is the only point where that is
314
+ * still cheap to fix.
315
+ *
316
+ * @param where - the surface being guarded, for a message that can be acted on.
317
+ * @param bindings - the document about to leave a trusted boundary.
318
+ */
319
+ export function assertNoSecretMaterial(where, bindings) {
320
+ for (const [role, record] of Object.entries(bindings.roles)) {
321
+ for (const [field, value] of Object.entries(record)) {
322
+ if (typeof value !== 'string')
323
+ continue;
324
+ for (const shape of SECRET_SHAPES) {
325
+ if (!shape.test(value))
326
+ continue;
327
+ throw new Error(`${where}: the ${role} binding's ${field} looks like credential material. `
328
+ + 'A binding stores a reference the Host resolves, never a value.');
329
+ }
330
+ }
331
+ }
332
+ }
333
+ //# sourceMappingURL=bindings.js.map
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The Watch Core contract digests this build was written against.
3
+ *
4
+ * ADR-004 makes the Pydantic models in `watch-skill` the semantic source of
5
+ * truth for the wire, and the TypeScript types in this package a face over
6
+ * them. The risk in that arrangement is drift: the engine adds a field, the
7
+ * Workspace keeps compiling, and the mismatch first shows up as a value that
8
+ * is quietly `undefined` in production.
9
+ *
10
+ * These digests close that. Watch Core reports the same values in its
11
+ * handshake, computed from the models themselves, and the Bridge compares them
12
+ * on connect. A family that disagrees disables the Watch features that depend
13
+ * on it and names both sides, instead of failing later somewhere unrelated.
14
+ *
15
+ * Regenerate with `python scripts/gen_bridge_schemas.py` in `watch-skill`, then
16
+ * copy the `families` block from `schemas/bridge/manifest.json`.
17
+ *
18
+ * @module @deepwatch/dsh-contracts/digests
19
+ */
20
+ /** One contract family: a group of fields consumers break on together. */
21
+ export type SchemaFamily = 'handshake' | 'evidence' | 'verification' | 'answer' | 'library' | 'error';
22
+ /**
23
+ * Digests generated from Watch Core's Bridge wire models.
24
+ *
25
+ * Source: `schemas/bridge/manifest.json`, written by
26
+ * `python scripts/gen_bridge_schemas.py` from
27
+ * `watch_skill.surfaces.bridge.wire`. Core computes the same values at import
28
+ * and reports them in the handshake, so these two are one artifact seen from
29
+ * two sides rather than two lists that have to be kept in step by hand.
30
+ *
31
+ * A digest changes when a family's schema changes in a way that matters:
32
+ * hashing is over the canonical, key-sorted document, so a reordered field
33
+ * does not read as a breaking change and a renamed one does.
34
+ */
35
+ export declare const EXPECTED_SCHEMA_DIGESTS: Readonly<Record<SchemaFamily, string>>;
36
+ /**
37
+ * Which Watch capability each contract family is load-bearing for.
38
+ *
39
+ * Used to disable *only* the affected features on a mismatch. A changed
40
+ * `library` schema should not take verification offline, and vice versa.
41
+ */
42
+ export declare const FAMILY_CAPABILITIES: Readonly<Record<SchemaFamily, readonly string[]>>;
43
+ /** One family whose digest did not match. */
44
+ export interface SchemaDrift {
45
+ readonly family: SchemaFamily;
46
+ /** What this build was written against. */
47
+ readonly expected: string;
48
+ /** What Watch Core reported, or null when it reported nothing for it. */
49
+ readonly actual: string | null;
50
+ /** Capabilities that must be treated as unavailable because of it. */
51
+ readonly affects: readonly string[];
52
+ }
53
+ /**
54
+ * Compare the handshake's digests against this build's.
55
+ *
56
+ * A family Watch Core does not report at all counts as drift with a null
57
+ * `actual`: an engine that has stopped publishing a digest is exactly as
58
+ * unverifiable as one publishing a different value, and treating silence as
59
+ * agreement is how this check would come to mean nothing.
60
+ *
61
+ * @param reported - the `schemaDigests` map from the handshake.
62
+ * @returns every family that disagrees, empty when the contract matches.
63
+ */
64
+ export declare function detectSchemaDrift(reported: Readonly<Record<string, string>>): readonly SchemaDrift[];
65
+ /**
66
+ * Whether an engine reporting no digests at all should be treated as drift.
67
+ *
68
+ * It should not. A Watch Core older than the schema manifest reports an empty
69
+ * map, and refusing to talk to it would break a working setup to enforce a
70
+ * check it predates. The Bridge surfaces that as "unverified contract" — a
71
+ * degraded, visible state — rather than as a mismatch it cannot substantiate.
72
+ *
73
+ * @param reported - the `schemaDigests` map from the handshake.
74
+ * @returns true when the engine published nothing to compare.
75
+ */
76
+ export declare function isContractUnverified(reported: Readonly<Record<string, string>> | null | undefined): boolean;
77
+ //# sourceMappingURL=digests.d.ts.map
package/lib/digests.js ADDED
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The Watch Core contract digests this build was written against.
3
+ *
4
+ * ADR-004 makes the Pydantic models in `watch-skill` the semantic source of
5
+ * truth for the wire, and the TypeScript types in this package a face over
6
+ * them. The risk in that arrangement is drift: the engine adds a field, the
7
+ * Workspace keeps compiling, and the mismatch first shows up as a value that
8
+ * is quietly `undefined` in production.
9
+ *
10
+ * These digests close that. Watch Core reports the same values in its
11
+ * handshake, computed from the models themselves, and the Bridge compares them
12
+ * on connect. A family that disagrees disables the Watch features that depend
13
+ * on it and names both sides, instead of failing later somewhere unrelated.
14
+ *
15
+ * Regenerate with `python scripts/gen_bridge_schemas.py` in `watch-skill`, then
16
+ * copy the `families` block from `schemas/bridge/manifest.json`.
17
+ *
18
+ * @module @deepwatch/dsh-contracts/digests
19
+ */
20
+ /**
21
+ * Digests generated from Watch Core's Bridge wire models.
22
+ *
23
+ * Source: `schemas/bridge/manifest.json`, written by
24
+ * `python scripts/gen_bridge_schemas.py` from
25
+ * `watch_skill.surfaces.bridge.wire`. Core computes the same values at import
26
+ * and reports them in the handshake, so these two are one artifact seen from
27
+ * two sides rather than two lists that have to be kept in step by hand.
28
+ *
29
+ * A digest changes when a family's schema changes in a way that matters:
30
+ * hashing is over the canonical, key-sorted document, so a reordered field
31
+ * does not read as a breaking change and a renamed one does.
32
+ */
33
+ export const EXPECTED_SCHEMA_DIGESTS = {
34
+ answer: 'sha256:2abc33bc76abe07486446bae94c70211',
35
+ error: 'sha256:f83b04be5dffc2dfab6963f071c7455a',
36
+ evidence: 'sha256:3facf1e0c00ffb61e9724d0a17b5d589',
37
+ handshake: 'sha256:215466e1a4d3ea526e71c5a162bf861b',
38
+ library: 'sha256:cb1a1721b33ccca2e25ea000fe41c7f1',
39
+ verification: 'sha256:0f381733ba98849b8335ad0fa1534534',
40
+ };
41
+ /**
42
+ * Which Watch capability each contract family is load-bearing for.
43
+ *
44
+ * Used to disable *only* the affected features on a mismatch. A changed
45
+ * `library` schema should not take verification offline, and vice versa.
46
+ */
47
+ export const FAMILY_CAPABILITIES = {
48
+ handshake: ['watch.video.query', 'watch.library.search', 'watch.verification.run'],
49
+ evidence: ['watch.video.query', 'watch.evidence.resolve'],
50
+ verification: ['watch.verification.run'],
51
+ answer: ['watch.video.query'],
52
+ library: ['watch.library.search'],
53
+ error: [],
54
+ };
55
+ /**
56
+ * Compare the handshake's digests against this build's.
57
+ *
58
+ * A family Watch Core does not report at all counts as drift with a null
59
+ * `actual`: an engine that has stopped publishing a digest is exactly as
60
+ * unverifiable as one publishing a different value, and treating silence as
61
+ * agreement is how this check would come to mean nothing.
62
+ *
63
+ * @param reported - the `schemaDigests` map from the handshake.
64
+ * @returns every family that disagrees, empty when the contract matches.
65
+ */
66
+ export function detectSchemaDrift(reported) {
67
+ const drift = [];
68
+ for (const [family, expected] of Object.entries(EXPECTED_SCHEMA_DIGESTS)) {
69
+ const actual = reported[family];
70
+ if (actual === expected)
71
+ continue;
72
+ drift.push({
73
+ family: family,
74
+ expected,
75
+ actual: actual ?? null,
76
+ affects: FAMILY_CAPABILITIES[family],
77
+ });
78
+ }
79
+ return drift;
80
+ }
81
+ /**
82
+ * Whether an engine reporting no digests at all should be treated as drift.
83
+ *
84
+ * It should not. A Watch Core older than the schema manifest reports an empty
85
+ * map, and refusing to talk to it would break a working setup to enforce a
86
+ * check it predates. The Bridge surfaces that as "unverified contract" — a
87
+ * degraded, visible state — rather than as a mismatch it cannot substantiate.
88
+ *
89
+ * @param reported - the `schemaDigests` map from the handshake.
90
+ * @returns true when the engine published nothing to compare.
91
+ */
92
+ export function isContractUnverified(reported) {
93
+ // Absent and empty are the same answer: nothing was published to compare.
94
+ // The type says this map is always present, and the type does not survive
95
+ // the wire — this value came out of `JSON.parse` of whatever the engine
96
+ // actually sent. An engine that omits the field used to take `Object.keys`
97
+ // straight into a TypeError, which escaped `connect()` as an unhandled
98
+ // throw and lost the degraded state this function exists to produce.
99
+ if (reported === null || reported === undefined || typeof reported !== 'object')
100
+ return true;
101
+ return Object.keys(reported).length === 0;
102
+ }
103
+ //# sourceMappingURL=digests.js.map