@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.
package/lib/query.js ADDED
@@ -0,0 +1,431 @@
1
+ /**
2
+ * The read plane: how a Watch mode asks the host what it should render.
3
+ *
4
+ * Watch contributes tools, and a tool result is how evidence reaches the
5
+ * *conversation*. It is not how a surface populates itself. A
6
+ * `conversation.view` entry is handed `{ inspect, onInspectDone }` and nothing
7
+ * else, so Live, Memory, Library and Compare had no way to obtain their own
8
+ * data and each defaulted to an empty array. Four of the seven modes rendered
9
+ * an honest empty state, which is a truthful surface and not a working one.
10
+ *
11
+ * The seam this uses is DSH's own. `packages/typert` defines Remote services
12
+ * dispatched through a Gateway that already owns request correlation, abort
13
+ * signals and structured failure, and `ctx.remote` is the client face of it.
14
+ * An earlier note in this distribution described `ctx.remote` as an event bus
15
+ * rather than a query client. That is true of `$on`/`$dispatch` and misses the
16
+ * typed invocation path beside them, and the conclusion drawn from it -- that
17
+ * populating these modes would mean building a second data path -- was the
18
+ * wrong one. Nothing here is a second path.
19
+ *
20
+ * Every operation is enumerated. There is no open `operation: string` with a
21
+ * free-form `params` object: a request is one member of a discriminated union
22
+ * or it is refused, so a surface cannot ask for something the host does not
23
+ * implement and the host cannot receive a shape it did not expect.
24
+ *
25
+ * Four properties this exists to guarantee.
26
+ *
27
+ * **Reads cannot express a write.** Every operation answers a question. A
28
+ * request that changes something is not in the union, so a surface cannot
29
+ * acquire a side effect by accident and captured or model-generated content
30
+ * reaching these fields cannot become an action.
31
+ *
32
+ * **Nothing here names a location.** Identifiers are drawn from a charset with
33
+ * no separator, no colon and no dot-dot, so a parameter cannot carry a
34
+ * filesystem path, a UNC share, an executable name or a storage URL. The host
35
+ * decides where it reads; the caller only says which record.
36
+ *
37
+ * **Every answer carries a revision, and a stale one is dropped.** Two reads
38
+ * issued in order can return out of order, and a surface that renders whichever
39
+ * arrived last shows older data than it had a moment ago -- intermittently, and
40
+ * under load, which is where that bug survives review.
41
+ *
42
+ * **Everything is bounded.** Request size, string length, array length, nesting
43
+ * depth, identifier length and cursor length all have limits, because the cost
44
+ * of a malformed request must not be a function of how malformed it is.
45
+ *
46
+ * Cancellation is deliberately absent from this module. Typert dispatches with
47
+ * an `AbortSignal`, and a second cancellation protocol carried in the payload
48
+ * would be a way for the two to disagree about whether a call is still live.
49
+ *
50
+ * Browser-safe like the rest of this package: no Node imports, no runtime
51
+ * identity, nothing a client bundle would have to deduplicate.
52
+ *
53
+ * @module @deepwatch/dsh-contracts/query
54
+ */
55
+ import { watchError } from './index.js';
56
+ /** The read-plane contract version this build speaks. */
57
+ export const WATCH_QUERY_PROTOCOL_VERSION = 1;
58
+ /** The oldest read-plane contract this build still answers. */
59
+ export const WATCH_QUERY_PROTOCOL_MIN = 1;
60
+ /**
61
+ * Every bound the read plane enforces.
62
+ *
63
+ * Stated in one place so a reviewer can see the whole budget at once, and so a
64
+ * test can assert against the same numbers the parser uses.
65
+ */
66
+ export const QUERY_LIMITS = {
67
+ /** A whole request, serialised. Generous for a query, useless as a channel. */
68
+ requestBytes: 8192,
69
+ /** Any single string the caller supplies. */
70
+ stringLength: 2048,
71
+ /** A free-text search term. Shorter than a general string on purpose. */
72
+ queryLength: 512,
73
+ /** Any array the caller supplies. */
74
+ arrayLength: 64,
75
+ /** How deep a params object may nest before it is refused. */
76
+ depth: 6,
77
+ /** An identifier: record ids, session ids, scope names. */
78
+ identifierLength: 128,
79
+ /** A request id. */
80
+ requestIdLength: 64,
81
+ /** A cursor, which the host issued and the caller returns unchanged. */
82
+ cursorLength: 512,
83
+ /** The most records one page may carry, whatever the caller asked for. */
84
+ limit: 200,
85
+ /** The longest a surface may wait before it must show something. */
86
+ deadlineMs: 30_000,
87
+ };
88
+ /** The namespaces a mode may read from. Reads only; there is no write here. */
89
+ export const QUERY_NAMESPACES = ['library', 'memory', 'compare', 'live'];
90
+ /**
91
+ * Every operation the host implements, by namespace.
92
+ *
93
+ * The parser rejects anything absent from this table, so adding an operation
94
+ * is one edit and forgetting to implement one is a refusal rather than an
95
+ * undefined call.
96
+ */
97
+ export const QUERY_OPERATIONS = {
98
+ library: ['search', 'get'],
99
+ memory: ['list', 'get'],
100
+ compare: ['pair'],
101
+ live: ['state'],
102
+ };
103
+ /** Encode a cursor. Opaque to the caller, checkable by the host. */
104
+ export function encodeCursor(cursor) {
105
+ return [
106
+ 'v1', cursor.namespace, cursor.operation, cursor.scope,
107
+ String(cursor.revision), String(cursor.offset),
108
+ ].join(':');
109
+ }
110
+ /**
111
+ * Decode a cursor and confirm it belongs here.
112
+ *
113
+ * Returns null for anything that does not decode or does not match the scope
114
+ * it is being replayed into. The caller turns that into `cursor_expired`,
115
+ * which is the honest description: the host cannot serve it, and saying why in
116
+ * more detail would describe another session's state.
117
+ */
118
+ export function decodeCursor(value, expected) {
119
+ if (value.length > QUERY_LIMITS.cursorLength)
120
+ return null;
121
+ const parts = value.split(':');
122
+ if (parts.length !== 6 || parts[0] !== 'v1')
123
+ return null;
124
+ const [, namespace, operation, scope, revision, offset] = parts;
125
+ if (namespace !== expected.namespace)
126
+ return null;
127
+ if (operation !== expected.operation)
128
+ return null;
129
+ if (scope !== expected.scope)
130
+ return null;
131
+ if (!isSafeCount(Number(revision)) || Number(revision) !== expected.revision)
132
+ return null;
133
+ if (!isSafeCount(Number(offset)))
134
+ return null;
135
+ return {
136
+ namespace: expected.namespace,
137
+ operation,
138
+ scope,
139
+ revision: Number(revision),
140
+ offset: Number(offset),
141
+ };
142
+ }
143
+ // ── primitives ──────────────────────────────────────────────────────────────
144
+ /** A non-negative safe integer. Protocol numbers and revisions must be one. */
145
+ export function isSafeCount(value) {
146
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
147
+ }
148
+ /**
149
+ * An identifier this contract will carry.
150
+ *
151
+ * No slash, no backslash, no colon, no leading dot: an id cannot become a
152
+ * relative path, an absolute path, a UNC share, a drive letter or a URL. This
153
+ * is the single reason a caller cannot name a location.
154
+ */
155
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
156
+ /** Whether a value is an identifier of an acceptable shape and length. */
157
+ export function isIdentifier(value) {
158
+ return typeof value === 'string'
159
+ && value.length > 0
160
+ && value.length <= QUERY_LIMITS.identifierLength
161
+ && !value.includes('..')
162
+ && IDENTIFIER.test(value);
163
+ }
164
+ /** Whether a value is a namespace this host serves. */
165
+ export function isQueryNamespace(value) {
166
+ return typeof value === 'string' && QUERY_NAMESPACES.includes(value);
167
+ }
168
+ /** Whether an operation exists in a namespace. */
169
+ export function isQueryOperation(namespace, operation) {
170
+ return typeof operation === 'string'
171
+ && QUERY_OPERATIONS[namespace].includes(operation);
172
+ }
173
+ /** Negotiate the read-plane contract, or null when there is no overlap. */
174
+ export function negotiateQueryProtocol(peerMin, peerMax) {
175
+ if (!isSafeCount(peerMin) || !isSafeCount(peerMax))
176
+ return null;
177
+ const agreed = Math.min(peerMax, WATCH_QUERY_PROTOCOL_VERSION);
178
+ return agreed >= Math.max(peerMin, WATCH_QUERY_PROTOCOL_MIN) ? agreed : null;
179
+ }
180
+ /**
181
+ * Whether an arriving snapshot is newer than what a surface already shows.
182
+ *
183
+ * The equal case is deliberately false. Re-rendering an identical revision
184
+ * costs a frame and gains nothing, and treating equal as newer would let two
185
+ * in-flight answers to the same revision fight.
186
+ */
187
+ export function isNewerRevision(arriving, showing) {
188
+ if (!isSafeCount(arriving))
189
+ return false;
190
+ if (showing === null)
191
+ return true;
192
+ return arriving > showing;
193
+ }
194
+ /** Bring a caller deadline inside what the host will honour. */
195
+ export function clampDeadline(requested) {
196
+ if (!isSafeCount(requested) || requested === 0)
197
+ return QUERY_LIMITS.deadlineMs;
198
+ return Math.min(requested, QUERY_LIMITS.deadlineMs);
199
+ }
200
+ /** Bring a caller page size inside what the host will return. */
201
+ export function clampLimit(requested) {
202
+ if (!isSafeCount(requested) || requested === 0)
203
+ return QUERY_LIMITS.limit;
204
+ return Math.min(requested, QUERY_LIMITS.limit);
205
+ }
206
+ /** How deeply a value nests, stopping as soon as the budget is blown. */
207
+ function depthOf(value, budget) {
208
+ if (budget < 0)
209
+ return Number.POSITIVE_INFINITY;
210
+ if (Array.isArray(value)) {
211
+ let deepest = 1;
212
+ for (const entry of value)
213
+ deepest = Math.max(deepest, 1 + depthOf(entry, budget - 1));
214
+ return deepest;
215
+ }
216
+ if (typeof value === 'object' && value !== null) {
217
+ let deepest = 1;
218
+ for (const entry of Object.values(value))
219
+ deepest = Math.max(deepest, 1 + depthOf(entry, budget - 1));
220
+ return deepest;
221
+ }
222
+ return 0;
223
+ }
224
+ /** Build the refusal a host returns when a read could not be produced. */
225
+ export function queryRefusal(code, message, fix, options = {}) {
226
+ const retryableByDefault = code === 'deadline_exceeded' || code === 'unavailable';
227
+ return watchError(`watch.query.${code}`, message, fix, {
228
+ retryable: options.retryable ?? retryableByDefault,
229
+ correlationId: options.requestId ?? null,
230
+ });
231
+ }
232
+ // ── request parsing ─────────────────────────────────────────────────────────
233
+ const refuse = (code, message, fix) => queryRefusal(code, message, fix);
234
+ /** Parse the parameters of one operation, or refuse them. */
235
+ function parseParams(namespace, operation, raw) {
236
+ const strings = (value, max) => typeof value === 'string' && value.length <= max;
237
+ if (namespace === 'library' && operation === 'search') {
238
+ if (!strings(raw.query, QUERY_LIMITS.queryLength)) {
239
+ return refuse('malformed_request', `library/search needs a query string of at most ${String(QUERY_LIMITS.queryLength)} characters.`, 'Shorten the search term.');
240
+ }
241
+ const modalities = raw.modalities ?? [];
242
+ if (!Array.isArray(modalities) || modalities.length > QUERY_LIMITS.arrayLength) {
243
+ return refuse('malformed_request', 'library/search modalities must be an array within bounds.', `Send at most ${String(QUERY_LIMITS.arrayLength)} modalities.`);
244
+ }
245
+ if (!modalities.every(entry => isIdentifier(entry))) {
246
+ return refuse('malformed_request', 'every modality must be a plain identifier.', 'Modalities carry no path separators.');
247
+ }
248
+ return { ok: true, value: {
249
+ query: raw.query, limit: clampLimit(raw.limit), modalities,
250
+ } };
251
+ }
252
+ if (namespace === 'library' && operation === 'get') {
253
+ if (!isIdentifier(raw.recordId)) {
254
+ return refuse('malformed_request', 'library/get needs a recordId identifier.', 'A record id carries no path separator, colon or dot-dot.');
255
+ }
256
+ return { ok: true, value: { recordId: raw.recordId } };
257
+ }
258
+ if (namespace === 'memory' && operation === 'list') {
259
+ if (!isIdentifier(raw.scope)) {
260
+ return refuse('malformed_request', 'memory/list needs a scope identifier.', 'A scope names a store the host knows, not a location.');
261
+ }
262
+ return { ok: true, value: {
263
+ scope: raw.scope, limit: clampLimit(raw.limit),
264
+ } };
265
+ }
266
+ if (namespace === 'memory' && operation === 'get') {
267
+ if (!isIdentifier(raw.cardId)) {
268
+ return refuse('malformed_request', 'memory/get needs a cardId identifier.', 'Send a card id.');
269
+ }
270
+ return { ok: true, value: { cardId: raw.cardId } };
271
+ }
272
+ if (namespace === 'compare' && operation === 'pair') {
273
+ if (!isIdentifier(raw.leftId) || !isIdentifier(raw.rightId)) {
274
+ return refuse('malformed_request', 'compare/pair needs two record identifiers.', 'Send leftId and rightId.');
275
+ }
276
+ return { ok: true, value: {
277
+ leftId: raw.leftId, rightId: raw.rightId,
278
+ } };
279
+ }
280
+ if (namespace === 'live' && operation === 'state') {
281
+ if (Object.keys(raw).length > 0) {
282
+ return refuse('malformed_request', 'live/state takes no parameters.', 'Send an empty params object.');
283
+ }
284
+ const empty = {};
285
+ return { ok: true, value: empty };
286
+ }
287
+ return refuse('unknown_operation', `${namespace}/${operation} is not implemented.`, `Use one of: ${QUERY_OPERATIONS[namespace].join(', ')}.`);
288
+ }
289
+ /**
290
+ * Validate a request at the boundary.
291
+ *
292
+ * Everything crossing into the host is parsed here, including requests this
293
+ * distribution's own client produced: a surface is reachable by anything that
294
+ * can reach the page, and "our own code sent it" is an assumption rather than
295
+ * a guarantee. Returns the normalised request, or the refusal to send back.
296
+ */
297
+ export function parseQueryRequest(value) {
298
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
299
+ return refuse('malformed_request', 'A query request must be an object.', 'Send a QueryRequest.');
300
+ }
301
+ // Size first: everything after this walks the structure, and the cost of
302
+ // rejecting a request must not scale with how large the caller made it.
303
+ let serialised;
304
+ try {
305
+ serialised = JSON.stringify(value);
306
+ }
307
+ catch {
308
+ return refuse('malformed_request', 'A query request must be serialisable.', 'Remove cycles and non-JSON values.');
309
+ }
310
+ if (serialised.length > QUERY_LIMITS.requestBytes) {
311
+ return refuse('request_too_large', `A query request may be at most ${String(QUERY_LIMITS.requestBytes)} bytes.`, 'Narrow the query, or page with the cursor the host returned.');
312
+ }
313
+ const raw = value;
314
+ if (!isSafeCount(raw.protocol) || negotiateQueryProtocol(raw.protocol, raw.protocol) === null) {
315
+ return refuse('protocol_mismatch', `The request declares read-plane protocol ${String(raw.protocol)}; this host speaks `
316
+ + `${String(WATCH_QUERY_PROTOCOL_MIN)}-${String(WATCH_QUERY_PROTOCOL_VERSION)}.`, 'Upgrade the half that is behind; the handshake reports both versions.');
317
+ }
318
+ if (typeof raw.requestId !== 'string' || !isIdentifier(raw.requestId)
319
+ || raw.requestId.length > QUERY_LIMITS.requestIdLength) {
320
+ return refuse('malformed_request', `A requestId must be an identifier of at most ${String(QUERY_LIMITS.requestIdLength)} characters.`, 'Generate one per request; it appears in logs and correlates the answer.');
321
+ }
322
+ if (!isQueryNamespace(raw.namespace)) {
323
+ return refuse('unknown_namespace', `${String(raw.namespace)} is not a readable namespace.`, `Use one of: ${QUERY_NAMESPACES.join(', ')}.`);
324
+ }
325
+ if (!isQueryOperation(raw.namespace, raw.operation)) {
326
+ return refuse('unknown_operation', `${raw.namespace}/${String(raw.operation)} is not implemented.`, `Use one of: ${QUERY_OPERATIONS[raw.namespace].join(', ')}.`);
327
+ }
328
+ if (raw.cursor !== null && raw.cursor !== undefined
329
+ && (typeof raw.cursor !== 'string' || raw.cursor.length > QUERY_LIMITS.cursorLength)) {
330
+ return refuse('malformed_request', `A cursor must be the string the host issued, at most ${String(QUERY_LIMITS.cursorLength)} characters.`, 'Pass nextCursor back unchanged, or null to start a new snapshot.');
331
+ }
332
+ if (typeof raw.params !== 'object' || raw.params === null || Array.isArray(raw.params)) {
333
+ return refuse('malformed_request', 'Query params must be an object.', 'Send params as an object.');
334
+ }
335
+ if (depthOf(raw.params, QUERY_LIMITS.depth) > QUERY_LIMITS.depth) {
336
+ return refuse('request_too_large', `Query params may nest at most ${String(QUERY_LIMITS.depth)} deep.`, 'Flatten the request; the read plane takes no nested structures.');
337
+ }
338
+ const params = parseParams(raw.namespace, raw.operation, raw.params);
339
+ if (!params.ok)
340
+ return params;
341
+ return {
342
+ ok: true,
343
+ value: {
344
+ protocol: raw.protocol,
345
+ requestId: raw.requestId,
346
+ namespace: raw.namespace,
347
+ operation: raw.operation,
348
+ deadlineMs: clampDeadline(raw.deadlineMs),
349
+ cursor: raw.cursor ?? null,
350
+ params: params.value,
351
+ },
352
+ };
353
+ }
354
+ // ── response parsing ────────────────────────────────────────────────────────
355
+ /**
356
+ * Validate a snapshot before a surface renders it.
357
+ *
358
+ * The host is trusted to be the host and not trusted to be correct. A response
359
+ * that does not satisfy this contract is a defect somewhere, and rendering it
360
+ * anyway turns a defect into a wrong answer displayed confidently.
361
+ */
362
+ export function parseQuerySnapshot(value, parseItem) {
363
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
364
+ return refuse('malformed_response', 'A snapshot must be an object.', 'The host returned something else.');
365
+ }
366
+ const raw = value;
367
+ if (!isSafeCount(raw.protocol) || negotiateQueryProtocol(raw.protocol, raw.protocol) === null) {
368
+ return refuse('protocol_mismatch', `The host answered with read-plane protocol ${String(raw.protocol)}.`, 'Upgrade the half that is behind.');
369
+ }
370
+ if (typeof raw.requestId !== 'string') {
371
+ return refuse('malformed_response', 'A snapshot must name the request it answers.', 'Report this as a defect.');
372
+ }
373
+ if (!isSafeCount(raw.revision)) {
374
+ return refuse('malformed_response', 'A snapshot must carry a non-negative revision.', 'Without one, a stale answer cannot be told from a fresh one.');
375
+ }
376
+ if (!Array.isArray(raw.items)) {
377
+ return refuse('malformed_response', 'A snapshot must carry an items array.', 'Report this as a defect.');
378
+ }
379
+ if (raw.nextCursor !== null && typeof raw.nextCursor !== 'string') {
380
+ return refuse('malformed_response', 'nextCursor must be a string or null.', 'Report this as a defect.');
381
+ }
382
+ if (typeof raw.complete !== 'boolean') {
383
+ return refuse('malformed_response', 'A snapshot must say whether it is complete.', 'A partial answer presented as whole is worse than no answer.');
384
+ }
385
+ const items = [];
386
+ for (const entry of raw.items) {
387
+ const parsed = parseItem(entry);
388
+ if (parsed === null) {
389
+ return refuse('malformed_response', 'A record in the snapshot did not satisfy its contract.', 'Report this as a defect; the surface will not render a shape it cannot read.');
390
+ }
391
+ items.push(parsed);
392
+ }
393
+ return {
394
+ ok: true,
395
+ value: {
396
+ protocol: raw.protocol,
397
+ requestId: raw.requestId,
398
+ revision: raw.revision,
399
+ items,
400
+ nextCursor: raw.nextCursor ?? null,
401
+ complete: raw.complete,
402
+ },
403
+ };
404
+ }
405
+ /** Read a Library record off the wire, or null when it is not one. */
406
+ export function parseLibraryRecord(value) {
407
+ if (typeof value !== 'object' || value === null)
408
+ return null;
409
+ const raw = value;
410
+ if (!isIdentifier(raw.recordId))
411
+ return null;
412
+ if (typeof raw.title !== 'string' || raw.title.length > QUERY_LIMITS.stringLength)
413
+ return null;
414
+ if (typeof raw.modality !== 'string')
415
+ return null;
416
+ if (raw.capturedAt !== null && typeof raw.capturedAt !== 'string')
417
+ return null;
418
+ if (typeof raw.provenance !== 'string')
419
+ return null;
420
+ if (!Array.isArray(raw.evidenceIds) || !raw.evidenceIds.every(id => isIdentifier(id)))
421
+ return null;
422
+ return {
423
+ recordId: raw.recordId,
424
+ title: raw.title,
425
+ modality: raw.modality,
426
+ capturedAt: raw.capturedAt ?? null,
427
+ provenance: raw.provenance,
428
+ evidenceIds: raw.evidenceIds,
429
+ };
430
+ }
431
+ //# sourceMappingURL=query.js.map
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Whether a role can actually run, kept as four separate facts.
3
+ *
4
+ * This module exists because they were one. A provider row showed a green dot
5
+ * the moment a credential was saved, and a person reasonably read that as "the
6
+ * product is ready" — so they typed a prompt, and the runtime routed it to a
7
+ * provider they had never configured, failed on a missing environment
8
+ * variable, and left a failed turn in their session. Every step of that was a
9
+ * consequence of one indicator standing for four different questions:
10
+ *
11
+ * 1. is there a credential? {@link ProviderCredentialStatus}
12
+ * 2. does the provider answer? {@link ProviderReachability}
13
+ * 3. is a model chosen? {@link ModelSelectionStatus}
14
+ * 4. is a role bound and runnable? {@link RoleBindingStatus}
15
+ *
16
+ * A credential is the *first* of those and implies none of the others.
17
+ * {@link roleReadiness} is the only thing in this product allowed to answer
18
+ * "ready", and it requires all four to line up, plus a route that supports the
19
+ * role and a policy state that permits the request.
20
+ *
21
+ * **Nothing here ever carries secret material.** Not a value, not a prefix, a
22
+ * suffix, a length or a hash. A credential appears in these types only as a
23
+ * *reference* — an opaque handle the Host can resolve — and as a status word.
24
+ * `configured_unverified` is the honest state after a save: something is
25
+ * stored, and nobody has asked the provider whether it works.
26
+ *
27
+ * Verification is deliberately not automatic. Saving a credential must not
28
+ * quietly spend a request, and discovering models must be distinguishable from
29
+ * a billable completion — so both are user actions with their own results, and
30
+ * {@link ProviderReachability} stays `unknown` until one of them runs.
31
+ *
32
+ * @module @deepwatch/dsh-contracts/readiness
33
+ */
34
+ /**
35
+ * What is known about a stored credential — never anything about its value.
36
+ *
37
+ * `configured_unverified` is the state a save produces and the one the UI has
38
+ * to be honest about: it means stored, not working. `inaccessible` is separate
39
+ * from `absent` because a credential store that cannot be opened is a fault to
40
+ * report, not an empty slot to fill.
41
+ */
42
+ export type ProviderCredentialStatus =
43
+ /** No credential is stored for this provider. */
44
+ 'absent'
45
+ /** Stored, and never checked against the provider. The state after a save. */
46
+ | 'configured_unverified'
47
+ /** A real request to the provider accepted it. */
48
+ | 'verified'
49
+ /** A real request to the provider rejected it. */
50
+ | 'rejected'
51
+ /** A credential is recorded but the store could not be read. */
52
+ | 'inaccessible';
53
+ /**
54
+ * Whether the provider itself answered, as distinct from whether it liked the
55
+ * credential.
56
+ *
57
+ * `unknown` is the default and stays the default: this product does not
58
+ * contact a provider because a settings page was opened.
59
+ */
60
+ export type ProviderReachability =
61
+ /** Never contacted from this installation. */
62
+ 'unknown' | 'reachable'
63
+ /** Contacted and no usable answer came back. */
64
+ | 'unreachable'
65
+ /** Answered, and declined this request for rate reasons. */
66
+ | 'rate_limited'
67
+ /** Answered, and rejected the credential. */
68
+ | 'unauthorized';
69
+ /** Whether a model has been chosen, and whether the choice still holds. */
70
+ export type ModelSelectionStatus =
71
+ /** Nothing chosen. A provider with a credential is still in this state. */
72
+ 'none' | 'selected'
73
+ /** Chosen once, and the provider no longer lists it. */
74
+ | 'unavailable'
75
+ /** Chosen, and not a well-formed model id for this provider. */
76
+ | 'invalid';
77
+ /** Whether a role is wired to something that could run. */
78
+ export type RoleBindingStatus =
79
+ /** No binding. Never a silent fallback to another role's model. */
80
+ 'unbound'
81
+ /** A binding exists and has not been proved end to end. */
82
+ | 'bound_unverified'
83
+ /** Every requirement below is satisfied. The only state that may say "ready". */
84
+ | 'executable'
85
+ /** A binding exists and something forbids running it. */
86
+ | 'blocked';
87
+ /**
88
+ * Why a role is not executable.
89
+ *
90
+ * One reason per missing requirement, so the UI can name the exact next step
91
+ * rather than saying "not configured" and leaving a person to guess which of
92
+ * six things is missing.
93
+ */
94
+ export type ReadinessBlocker = 'no_binding' | 'provider_unknown' | 'credential_absent' | 'credential_rejected' | 'credential_inaccessible' | 'provider_untested' | 'provider_unreachable' | 'provider_rate_limited' | 'model_unset' | 'model_unavailable' | 'model_invalid' | 'route_lacks_role' | 'modality_unsupported' | 'consent_required' | 'policy_forbids' | 'contract_mismatch';
95
+ /** A modality a role may need a route to support. */
96
+ export type Modality = 'text' | 'vision' | 'audio' | 'embedding';
97
+ /**
98
+ * What a route can do.
99
+ *
100
+ * Supplied by whatever knows the provider catalogue; this module only compares
101
+ * it against what a role asks for.
102
+ */
103
+ export interface RouteCapability {
104
+ /** The provider route id, as the catalogue names it. */
105
+ readonly provider: string;
106
+ /** Roles this route can serve at all. */
107
+ readonly roles: readonly string[];
108
+ readonly modalities: readonly Modality[];
109
+ /** Model ids the provider listed, or null when nobody has asked it. */
110
+ readonly models: readonly string[] | null;
111
+ }
112
+ /**
113
+ * One role's binding, as stored.
114
+ *
115
+ * `credentialRef` is an opaque handle, never a value: the Host resolves it
116
+ * against its own credential store, and nothing that crosses into a browser or
117
+ * a log ever holds more than this string.
118
+ */
119
+ export interface RoleBinding {
120
+ readonly role: string;
121
+ readonly provider: string;
122
+ readonly model: string;
123
+ /** Opaque handle to a credential the Host holds. Never a secret. */
124
+ readonly credentialRef: string | null;
125
+ /** Modalities the bound work will actually need. */
126
+ readonly modalities: readonly Modality[];
127
+ }
128
+ /** Everything {@link roleReadiness} is allowed to consider. */
129
+ export interface ReadinessInputs {
130
+ readonly binding: RoleBinding | null;
131
+ readonly credential: ProviderCredentialStatus;
132
+ readonly reachability: ProviderReachability;
133
+ readonly model: ModelSelectionStatus;
134
+ readonly route: RouteCapability | null;
135
+ /** False when a required consent has not been granted for this work. */
136
+ readonly consentGranted: boolean;
137
+ /** False when policy forbids the request regardless of consent. */
138
+ readonly policyPermits: boolean;
139
+ /** False when the wire contract the route speaks is not one this build has. */
140
+ readonly contractMatches: boolean;
141
+ }
142
+ /** What the product knows about one role. */
143
+ export interface RoleReadiness {
144
+ readonly role: string;
145
+ readonly status: RoleBindingStatus;
146
+ /** Empty exactly when the status is `executable`. */
147
+ readonly blockers: readonly ReadinessBlocker[];
148
+ /** The single next thing to fix, or null when there is nothing to fix. */
149
+ readonly primaryBlocker: ReadinessBlocker | null;
150
+ }
151
+ /**
152
+ * Whether a role can run, and if not, exactly what is missing.
153
+ *
154
+ * The single gate. A caller may not assemble "ready" from parts: every surface
155
+ * that shows readiness, and both halves of preflight, ask this function, so
156
+ * there is one definition of executable and it is testable on its own.
157
+ *
158
+ * @param inputs - every fact that bears on the decision.
159
+ * @param role - the role being asked about.
160
+ * @returns the status and the blockers, in the order they must be fixed.
161
+ */
162
+ export declare function roleReadiness(role: string, inputs: ReadinessInputs): RoleReadiness;
163
+ /**
164
+ * Whether a role may be described to a person as ready.
165
+ *
166
+ * A helper rather than a comparison at each call site, because "ready" is
167
+ * exactly the word this whole module exists to stop being used loosely.
168
+ */
169
+ export declare function isExecutable(readiness: RoleReadiness): boolean;
170
+ /**
171
+ * One sentence a person can act on, for the blocker that matters most.
172
+ *
173
+ * Deliberately free of route ids, package names and environment variables:
174
+ * those belong in Diagnostics, and a person reading their first error should
175
+ * be told what to do rather than what broke internally.
176
+ */
177
+ export declare function blockerMessage(readiness: RoleReadiness): string | null;
178
+ /**
179
+ * Whether a status word may be drawn with the affordance that reads as "good".
180
+ *
181
+ * Only a proved binding earns it. `bound_unverified` deliberately does not:
182
+ * that is the state a green dot used to claim, and claiming it is what sent a
183
+ * prompt to an unconfigured provider.
184
+ */
185
+ export declare function isPositiveBindingStatus(status: RoleBindingStatus): boolean;
186
+ /**
187
+ * The accessible label for a status, so colour is never the only signal.
188
+ *
189
+ * Every surface uses these words, so a screen reader and a sighted reader are
190
+ * told the same thing, and a red or green dot is decoration on top of text
191
+ * rather than the fact itself.
192
+ */
193
+ export declare const BINDING_STATUS_LABEL: Readonly<Record<RoleBindingStatus, string>>;
194
+ /** The accessible label for a credential status. */
195
+ export declare const CREDENTIAL_STATUS_LABEL: Readonly<Record<ProviderCredentialStatus, string>>;
196
+ /** The accessible label for provider reachability. */
197
+ export declare const REACHABILITY_LABEL: Readonly<Record<ProviderReachability, string>>;
198
+ //# sourceMappingURL=readiness.d.ts.map