dsh-realtime 0.2.1 → 0.2.3

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,520 @@
1
+ /**
2
+ * The settings surface: what a running plugin's settings are, and which of them a change can reach.
3
+ *
4
+ * `docs/control-plane-fields.md` is the design gate, and it classifies every field into one of three
5
+ * classes: **live** (read at the moment of use, so a change takes effect then), **session-bound** (it
6
+ * travelled in the provider's `session.start`, so only a new session can carry a new value) and
7
+ * **restart-bound** (claimed once against the web server's registry or enforced by the socket). This
8
+ * module is that classification in code, because a classification that lives only in prose cannot
9
+ * refuse anything: the design rule is *an affordance the protocol cannot honour is worse than no
10
+ * affordance*, and the only way to honour it is for the surface to know a setting's class and answer a
11
+ * change with the reason instead of applying it.
12
+ *
13
+ * Four properties are load-bearing, and each is a test rather than a promise:
14
+ *
15
+ * - **The value is read at the moment of use, from the plugin that owns it.** A setting holds a
16
+ * `get()`, and the consumer calls it where it acts — not a copy taken at apply time. That is what
17
+ * makes the change take effect on the next use, and it is why a stale copy is impossible rather than
18
+ * merely discouraged.
19
+ * - **A change is refused with a reason on the same channel it arrived on.** Unknown key, a class that
20
+ * cannot honour a change, a value that does not parse, a setter that rejects one — four different
21
+ * reasons, because they call for four different responses and collapsing them is the failure this
22
+ * project has already paid for once (see `realtime-responder/src/turn.ts`).
23
+ * - **A secret setting is write-only.** `redactSecrets` holds values that must never be spoken,
24
+ * journalled or handed to a page; a surface that echoed them back through `status` or a `set`
25
+ * outcome would breach `design.md` invariant 3 one layer out. A setting declared `secret` reports no
26
+ * value at all.
27
+ * - **Registration is a contract, checked at registration.** The declared `kind` must match what
28
+ * `get()` actually returns, and only a `live` setting may have a setter — so a setting that would
29
+ * render the wrong control, or claim a change it cannot apply, fails where it is declared rather
30
+ * than in a user's session.
31
+ *
32
+ * @module dsh-realtime/settings
33
+ */
34
+
35
+ import { REALTIME_ERROR_CODES, RealtimeError } from './error.ts'
36
+ import type { Journal } from './journal.ts'
37
+ import { redact } from './redact.ts'
38
+
39
+ /**
40
+ * How a setting's value is represented.
41
+ *
42
+ * The kind is what a text channel parses by and what a control renders for, so it is declared rather
43
+ * than inferred: a `number` setting and a `string` setting whose values happen to look alike are
44
+ * different controls and different refusals.
45
+ */
46
+ export type RealtimeSettingKind = 'string' | 'number' | 'boolean' | 'string-list'
47
+
48
+ /** The three classes of `docs/control-plane-fields.md`, in code. */
49
+ export type RealtimeSettingScope = 'live' | 'session' | 'restart'
50
+
51
+ /** Machine codes a refused change carries. Branch on the code, never on the reason's prose. */
52
+ export type RealtimeSettingRefusalCode =
53
+ /** No setting is registered under that key — usually a typo, and never a silent no-op. */
54
+ | 'UNKNOWN_SETTING'
55
+ /** The setting exists and its class cannot honour a change: it needs a reconnect, or a restart. */
56
+ | 'FROZEN_SETTING'
57
+ /** The value did not parse as the declared kind, or the owner's own validation rejected it. */
58
+ | 'INVALID_SETTING'
59
+
60
+ /** One setting, as the plugin that owns it declares it. */
61
+ export interface RealtimeSettingSpec<T> {
62
+ /**
63
+ * The setting's name within its owner, e.g. `sessionId`. The registry's key is
64
+ * `<owner>.<field>` — qualified, because two plugins may legitimately both hold a `sessionId` and a
65
+ * bare name would make one of them unreachable.
66
+ */
67
+ readonly field: string
68
+ /** How the value is represented on a text channel and in a control. */
69
+ readonly kind: RealtimeSettingKind
70
+ /** What a change to it would do. */
71
+ readonly scope: RealtimeSettingScope
72
+ /** One line for a status surface. Optional: the key is usually enough. */
73
+ readonly describe?: string
74
+ /**
75
+ * Whether the value must never be reported back.
76
+ *
77
+ * True for a setting that holds secrets rather than a setting that needs one: the surface reports
78
+ * `undefined` for the value and a change still applies. See the module note.
79
+ */
80
+ readonly secret?: boolean
81
+ /**
82
+ * The values this setting will accept right now, when it has a set worth picking from.
83
+ *
84
+ * Declared here rather than discovered by a surface, because the plugin that owns the field is the only
85
+ * party that knows where the candidates are: the responder's `sessionId` is every session the harness
86
+ * currently has live, read from a service it may or may not be mounted beside. Called at the moment the
87
+ * surface is asked, like {@link get} and for the same reason.
88
+ *
89
+ * An empty list is a legitimate answer and is not the same as omitting this: a picker with nothing to
90
+ * pick should render as a text field, and a surface cannot tell that from a field that never had a list.
91
+ * @returns the candidate values, in the order a picker should show them.
92
+ */
93
+ readonly choices?: () => readonly string[]
94
+ /** The value **now**. Called at the moment of use, never captured. */
95
+ readonly get: () => T
96
+ /**
97
+ * Validate and apply a parsed value. Present exactly when the class is `live`.
98
+ *
99
+ * Declared as a **method**, not as a property holding a function, so a spec typed to its own value is
100
+ * assignable to the erased form the registry stores: TypeScript checks a function-typed property
101
+ * contravariantly, which would refuse `(value: string) => void` where `(value: unknown) => void` is
102
+ * wanted and force every plugin to erase its own type at the call site. With a method signature the
103
+ * parameter is bivariant, and inference of `T` from `get` is what types the parameter — so a plugin
104
+ * writes the value's real type once and the surface accepts it.
105
+ *
106
+ * Throwing is the plugin's way to refuse a value its own rules reject (a non-empty id, a positive
107
+ * budget). The message is carried back as the reason, so it should name the constraint rather than
108
+ * repeat the value.
109
+ * @param value - the value parsed from a text frame, already matching the declared `kind`.
110
+ */
111
+ set?(value: T): void
112
+ }
113
+
114
+ /** One setting as the surface reports it. Detached: mutating one cannot reach the registry. */
115
+ export interface RealtimeSettingInfo {
116
+ /** `<owner>.<field>`, the key a change addresses. */
117
+ readonly key: string
118
+ /** The plugin that owns it, as it appears in Loader diagnostics. */
119
+ readonly owner: string
120
+ /** The setting's own name, for a control's label. */
121
+ readonly field: string
122
+ /** How the value is represented. */
123
+ readonly kind: RealtimeSettingKind
124
+ /** What a change to it would do. */
125
+ readonly scope: RealtimeSettingScope
126
+ /** The owner's one-line description, when it supplied one. */
127
+ readonly describe?: string
128
+ /**
129
+ * The values the setting will accept right now, when it declared a set worth picking from.
130
+ *
131
+ * Omitted when the owner declared none; `[]` when it declared one and it is currently empty — which a
132
+ * surface renders as a text field rather than as a picker with nothing in it.
133
+ */
134
+ readonly choices?: readonly string[]
135
+ /**
136
+ * The value now, or `undefined` when the setting is `secret`.
137
+ *
138
+ * Read through the owner's own `get()` at the moment the surface is asked, so it is what the plugin
139
+ * would actually use — not a copy taken at registration.
140
+ */
141
+ readonly value: unknown
142
+ }
143
+
144
+ /** A change that was applied. */
145
+ export interface RealtimeSettingApplied {
146
+ readonly ok: true
147
+ /** The setting that changed. */
148
+ readonly key: string
149
+ /**
150
+ * What the setting is **now**, re-read from its owner after the setter ran, and `undefined` for a
151
+ * secret. A setter may clamp or normalise what it was handed, so the answer to "what did that
152
+ * change?" is the value the plugin holds and not the value that was asked for.
153
+ */
154
+ readonly value: unknown
155
+ }
156
+
157
+ /** A change that was refused, with the reason to relay. */
158
+ export interface RealtimeSettingRefused {
159
+ readonly ok: false
160
+ /** The setting the change addressed, exactly as it was asked for. */
161
+ readonly key: string
162
+ /** Which class of refusal this is. */
163
+ readonly code: RealtimeSettingRefusalCode
164
+ /**
165
+ * One line, written to be relayed **verbatim** to whoever made the change — the same rule the
166
+ * controller's refusal follows one layer down. Redacted and bounded: it may echo a value the caller
167
+ * sent, and a caller is not always the party that is allowed to see it.
168
+ */
169
+ readonly reason: string
170
+ }
171
+
172
+ /** What attempting a change produced. */
173
+ export type RealtimeSettingOutcome = RealtimeSettingApplied | RealtimeSettingRefused
174
+
175
+ /** The classes a setting may declare, for validating a caller that is not TypeScript. */
176
+ const SCOPES: readonly RealtimeSettingScope[] = Object.freeze(['live', 'session', 'restart'])
177
+
178
+ /** The kinds a setting may declare, for the same reason. */
179
+ const KINDS: readonly RealtimeSettingKind[] = Object.freeze(['string', 'number', 'boolean', 'string-list'])
180
+
181
+ /** Field names are dotted onto an owner, so they must be a single identifier-shaped word. */
182
+ const FIELD_SHAPE = /^[A-Za-z][A-Za-z0-9]*$/
183
+
184
+ /** Longest reason carried out of a refusal, so an owner's error cannot flood a status surface. */
185
+ const MAX_REASON_CHARS = 200
186
+
187
+ /** One registered setting. The spec, plus the identity the registry derives from it. */
188
+ interface RegisteredSetting {
189
+ readonly key: string
190
+ readonly owner: string
191
+ readonly field: string
192
+ readonly kind: RealtimeSettingKind
193
+ readonly scope: RealtimeSettingScope
194
+ readonly describe?: string
195
+ readonly secret: boolean
196
+ readonly choices?: () => readonly string[]
197
+ readonly get: () => unknown
198
+ readonly set?: (value: unknown) => void
199
+ }
200
+
201
+ /**
202
+ * The registry of settings a running plugin can be steered by.
203
+ *
204
+ * Owned by the seam, like the journal, and for the same reason: it is an object every plugin in the
205
+ * bundle already holds, and a surface split across three of them would leave a reader — or a control
206
+ * plane — correlating three partial answers to one question.
207
+ *
208
+ * Not a service and not tied to a Cordis context: a plain object, so it can be constructed in a test
209
+ * and asked things without a harness. Registration returns a disposer rather than taking ownership of
210
+ * one, so the *contributing* fiber is what releases a plugin's settings — call it inside
211
+ * `ctx.effect`, exactly as a plugin registers a listener.
212
+ */
213
+ export class RealtimeSettings {
214
+ private readonly settings = new Map<string, RegisteredSetting>()
215
+
216
+ /**
217
+ * @param journal - the seam's journal. A successful change is recorded there as `config.changed`;
218
+ * see {@link apply} for what is deliberately not recorded.
219
+ */
220
+ constructor(private readonly journal: Pick<Journal, 'record'>) {}
221
+
222
+ /**
223
+ * Declare the settings one plugin owns, all-or-nothing.
224
+ *
225
+ * A malformed spec fails here rather than at the moment somebody tries to change it: the point of a
226
+ * declared kind is that a control and a parser agree with the plugin's own type, and a setting whose
227
+ * declaration does not match what it returns has neither.
228
+ * @param owner - the plugin's name, as it appears in Loader diagnostics. Becomes the key's prefix.
229
+ * @param specs - every setting this plugin declares.
230
+ * @returns the disposer that releases them. Call it inside the contributing fiber's effect.
231
+ * @throws RealtimeError `INVALID_SETTING` for a malformed owner, field, kind, scope or declaration.
232
+ * @throws RealtimeError `DUPLICATE_SETTING` for a key another registration already holds.
233
+ */
234
+ register(owner: string, specs: readonly RealtimeSettingSpec<unknown>[]): () => void {
235
+ if (typeof owner !== 'string' || owner.length === 0) {
236
+ throw new RealtimeError('a settings owner must be a non-empty string', REALTIME_ERROR_CODES.INVALID_SETTING)
237
+ }
238
+ const prepared: RegisteredSetting[] = []
239
+ const claimed = new Set<string>()
240
+ for (const spec of specs) {
241
+ const key = `${owner}.${String(spec.field)}`
242
+ if (typeof spec.field !== 'string' || !FIELD_SHAPE.test(spec.field)) {
243
+ throw new RealtimeError(
244
+ `a setting's field must be identifier-shaped, received "${String(spec.field)}"`,
245
+ REALTIME_ERROR_CODES.INVALID_SETTING,
246
+ )
247
+ }
248
+ if (claimed.has(key) || this.settings.has(key)) {
249
+ throw new RealtimeError(
250
+ `a setting named "${key}" is already registered`,
251
+ REALTIME_ERROR_CODES.DUPLICATE_SETTING,
252
+ )
253
+ }
254
+ if (!SCOPES.includes(spec.scope)) {
255
+ throw new RealtimeError(
256
+ `"${key}" declared an unknown scope "${String(spec.scope)}"`,
257
+ REALTIME_ERROR_CODES.INVALID_SETTING,
258
+ )
259
+ }
260
+ if (!KINDS.includes(spec.kind)) {
261
+ throw new RealtimeError(
262
+ `"${key}" declared an unknown kind "${String(spec.kind)}"`,
263
+ REALTIME_ERROR_CODES.INVALID_SETTING,
264
+ )
265
+ }
266
+ // A list of candidates is a claim about the values a *picker* offers, and a picker only exists for a
267
+ // string: numbers, booleans and lists each have one representation a control does not need help with.
268
+ if (spec.choices !== undefined && spec.kind !== 'string') {
269
+ throw new RealtimeError(
270
+ `"${key}" declares choices and the kind "${spec.kind}" — candidates are for a string setting`,
271
+ REALTIME_ERROR_CODES.INVALID_SETTING,
272
+ )
273
+ }
274
+ // A change can only reach a `live` field, and only a `live` field has anything to apply it. Two
275
+ // one-sided declarations, and both are refusals rather than warnings for the same reason: the
276
+ // first would offer a control that silently does nothing, the second would answer a change by
277
+ // doing nothing at all.
278
+ if (spec.scope === 'live' && spec.set === undefined) {
279
+ throw new RealtimeError(
280
+ `"${key}" is live and must declare how a change is applied`,
281
+ REALTIME_ERROR_CODES.INVALID_SETTING,
282
+ )
283
+ }
284
+ if (spec.scope !== 'live' && spec.set !== undefined) {
285
+ throw new RealtimeError(
286
+ `"${key}" is ${spec.scope}-bound and cannot declare a setter`,
287
+ REALTIME_ERROR_CODES.INVALID_SETTING,
288
+ )
289
+ }
290
+ // Read once, at registration: the declared kind is a claim about what this setting *is*, and a
291
+ // claim that does not hold here produces a control that shows the wrong thing for ever.
292
+ const now = spec.get()
293
+ if (!matchesKind(spec.kind, now)) {
294
+ throw new RealtimeError(
295
+ `"${key}" declares the kind "${spec.kind}" and returns ${describeValue(now)}`,
296
+ REALTIME_ERROR_CODES.INVALID_SETTING,
297
+ )
298
+ }
299
+ claimed.add(key)
300
+ prepared.push({
301
+ key,
302
+ owner,
303
+ field: spec.field,
304
+ kind: spec.kind,
305
+ scope: spec.scope,
306
+ ...spec.describe === undefined ? {} : { describe: spec.describe },
307
+ secret: spec.secret === true,
308
+ ...spec.choices === undefined ? {} : { choices: spec.choices },
309
+ get: spec.get,
310
+ ...spec.set === undefined ? {} : { set: spec.set },
311
+ })
312
+ }
313
+ for (const registered of prepared) this.settings.set(registered.key, registered)
314
+ return () => {
315
+ for (const registered of prepared) this.settings.delete(registered.key)
316
+ }
317
+ }
318
+
319
+ /**
320
+ * Every registered setting, in the order its plugins registered them.
321
+ *
322
+ * Registration order is composition order, which is what a status surface wants to show; sorting
323
+ * would be deterministic and would also hide which plugin arrived first, which is exactly the thing
324
+ * a reader is trying to work out when a row waits.
325
+ * @returns detached descriptions, each with the value read now.
326
+ */
327
+ list(): RealtimeSettingInfo[] {
328
+ return [...this.settings.values()].map(registered => this.describe(registered))
329
+ }
330
+
331
+ /**
332
+ * Describe one setting.
333
+ * @param key - `<owner>.<field>`.
334
+ * @returns the description, or `undefined` when nothing is registered under that key.
335
+ */
336
+ get(key: string): RealtimeSettingInfo | undefined {
337
+ const registered = this.settings.get(key)
338
+ return registered === undefined ? undefined : this.describe(registered)
339
+ }
340
+
341
+ /**
342
+ * Apply a change addressed as text, and say what happened.
343
+ *
344
+ * The one operation a control plane needs: it parses by the declared kind, refuses anything the
345
+ * setting's class cannot honour, hands the value to the owner's own setter and reports the value the
346
+ * owner now holds. A change that landed is journalled as `config.changed` **with its key and not its
347
+ * value** — the value of a secret setting is precisely the text that must not be retained, and a
348
+ * surface that journalled "the values it was given" would write them into the one record built to be
349
+ * read and pasted.
350
+ * @param key - the setting's `<owner>.<field>` key, exactly as {@link RealtimeSettingInfo.key} reports it.
351
+ * @param text - the value as a text frame carries it.
352
+ * @returns whether the change was applied, and either the value now or the reason it was refused.
353
+ */
354
+ apply(key: string, text: string): RealtimeSettingOutcome {
355
+ const registered = this.settings.get(key)
356
+ // Checked before the value is looked at: whether a setting can be changed at all is a property of
357
+ // the setting, and answering a malformed value first would tell a caller to fix a typo in a number
358
+ // where the real answer is that this field needs a restart.
359
+ if (registered === undefined) {
360
+ return this.refuse(key, 'UNKNOWN_SETTING', `no setting named "${key}" is registered`)
361
+ }
362
+ if (registered.scope !== 'live') return this.refuse(key, 'FROZEN_SETTING', frozenReason(registered))
363
+ const parsed = parseValue(registered.kind, text)
364
+ if (!parsed.ok) {
365
+ return this.refuse(
366
+ key,
367
+ 'INVALID_SETTING',
368
+ `"${key}" expects a ${registered.kind}, received ${describeText(text)}`,
369
+ )
370
+ }
371
+ try {
372
+ registered.set?.(parsed.value)
373
+ } catch (error) {
374
+ // The owner's own rules — a non-empty session id, a positive budget — are the ones a caller can
375
+ // act on, so its message is carried rather than replaced by a generic one.
376
+ return this.refuse(key, 'INVALID_SETTING', `"${key}" refused the change: ${reasonOf(error)}`)
377
+ }
378
+ this.journal.record('config.changed', { key })
379
+ return { ok: true, key, value: registered.secret ? undefined : registered.get() }
380
+ }
381
+
382
+ /**
383
+ * Build one detached description, reading the value now.
384
+ * @param registered - the stored setting.
385
+ * @returns the description, with no value at all when the setting is secret.
386
+ */
387
+ private describe(registered: RegisteredSetting): RealtimeSettingInfo {
388
+ return {
389
+ key: registered.key,
390
+ owner: registered.owner,
391
+ field: registered.field,
392
+ kind: registered.kind,
393
+ scope: registered.scope,
394
+ ...registered.describe === undefined ? {} : { describe: registered.describe },
395
+ ...registered.choices === undefined ? {} : { choices: registered.choices() },
396
+ value: registered.secret ? undefined : registered.get(),
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Build one refusal, redacted and bounded.
402
+ * @param key - the setting the change addressed.
403
+ * @param code - which class of refusal this is.
404
+ * @param reason - the line to relay.
405
+ * @returns the refusal to hand back.
406
+ */
407
+ private refuse(key: string, code: RealtimeSettingRefusalCode, reason: string): RealtimeSettingRefused {
408
+ // The reason may quote the value the caller sent, and a caller is not always entitled to read it:
409
+ // the shape arm runs over it, and the bound stops an owner's error message becoming the payload.
410
+ return { ok: false, key, code, reason: redact(reason).slice(0, MAX_REASON_CHARS) }
411
+ }
412
+ }
413
+
414
+ /**
415
+ * Why a setting of this class cannot take a change, in the words the person making the change needs.
416
+ *
417
+ * The two classes are different instructions — one reconnect, one restart — and naming which is the
418
+ * whole value of refusing rather than ignoring.
419
+ * @param registered - the frozen setting.
420
+ * @returns the reason.
421
+ */
422
+ function frozenReason(registered: RegisteredSetting): string {
423
+ return registered.scope === 'session'
424
+ ? `"${registered.key}" is fixed when the voice session opens — reconnect to apply a new value`
425
+ : `"${registered.key}" is claimed when the plugin loads — restart to change it`
426
+ }
427
+
428
+ /**
429
+ * Does this value match the kind the setting declared?
430
+ *
431
+ * `get()` is the plugin's own, so the check costs nothing at runtime and catches the failure that
432
+ * matters: a declaration that would render a control for one kind over a value of another.
433
+ * @param kind - the declared kind.
434
+ * @param value - what `get()` returned.
435
+ * @returns whether they agree.
436
+ */
437
+ function matchesKind(kind: RealtimeSettingKind, value: unknown): boolean {
438
+ switch (kind) {
439
+ case 'string': return typeof value === 'string'
440
+ case 'number': return typeof value === 'number' && Number.isFinite(value)
441
+ case 'boolean': return typeof value === 'boolean'
442
+ case 'string-list': return Array.isArray(value) && value.every(entry => typeof entry === 'string')
443
+ }
444
+ }
445
+
446
+ /**
447
+ * Describe a value's shape, for a registration that declared the wrong kind.
448
+ * @param value - whatever `get()` returned.
449
+ * @returns a short description, never the value's content.
450
+ */
451
+ function describeValue(value: unknown): string {
452
+ if (value === null) return 'null'
453
+ if (Array.isArray(value)) return 'an array'
454
+ return `a ${typeof value}`
455
+ }
456
+
457
+ /**
458
+ * Parse one text value into the declared kind.
459
+ *
460
+ * `string` cannot fail — every text is a string — so an empty value is deliberately *not* refused here:
461
+ * whether a particular string may be empty is the owner's rule, and it can say why. The other three
462
+ * kinds have exactly one representation each, because an ambiguous parse is a change that applies
463
+ * something the caller did not ask for.
464
+ * @param kind - the setting's declared kind.
465
+ * @param text - the value as it arrived.
466
+ * @returns the parsed value, or that it did not parse.
467
+ */
468
+ function parseValue(kind: RealtimeSettingKind, text: string): { readonly ok: true; readonly value: unknown } | { readonly ok: false } {
469
+ switch (kind) {
470
+ case 'string':
471
+ return { ok: true, value: text }
472
+ case 'number': {
473
+ const trimmed = text.trim()
474
+ if (trimmed.length === 0) return { ok: false }
475
+ const value = Number(trimmed)
476
+ return Number.isFinite(value) ? { ok: true, value } : { ok: false }
477
+ }
478
+ case 'boolean':
479
+ if (text !== 'true' && text !== 'false') return { ok: false }
480
+ return { ok: true, value: text === 'true' }
481
+ case 'string-list': {
482
+ // JSON, and only JSON: a list of secrets separated by commas cannot be split on a comma, and a
483
+ // rule that guessed would silently drop half a secret from the redaction list.
484
+ let parsed: unknown
485
+ try {
486
+ parsed = JSON.parse(text)
487
+ } catch {
488
+ return { ok: false }
489
+ }
490
+ if (!Array.isArray(parsed) || !parsed.every(entry => typeof entry === 'string')) return { ok: false }
491
+ return { ok: true, value: parsed }
492
+ }
493
+ }
494
+ }
495
+
496
+ /**
497
+ * Quote a received value back, redacted and bounded.
498
+ *
499
+ * Redacted because the echo travels to whoever asked and into whatever they render it in, and a caller
500
+ * that sent a credential to the right route by mistake should not have it handed back for display.
501
+ * @param text - the value as it arrived.
502
+ * @returns a short, safe quotation of it.
503
+ */
504
+ function describeText(text: string): string {
505
+ return JSON.stringify(redact(text).slice(0, 40))
506
+ }
507
+
508
+ /**
509
+ * The reason a setter refused, as a line to relay.
510
+ *
511
+ * A rejection that carries no message is still a rejection, and naming the absence is more useful than
512
+ * an empty string that reads as "no reason".
513
+ * @param error - whatever the setter threw.
514
+ * @returns a non-empty reason.
515
+ */
516
+ function reasonOf(error: unknown): string {
517
+ if (error instanceof Error && error.message.length > 0) return error.message
518
+ if (typeof error === 'string' && error.length > 0) return error
519
+ return 'the setting declined the value'
520
+ }