@mikrojs/native 0.18.0 → 0.18.2-next.20260826235606

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.
Files changed (69) hide show
  1. package/CMakeLists.txt +62 -1
  2. package/dist/index.d.ts +17 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +11 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/runtime/result/native-result.node-shim.d.ts +3 -0
  7. package/dist/runtime/result/native-result.node-shim.d.ts.map +1 -0
  8. package/dist/runtime/result/native-result.node-shim.js +41 -0
  9. package/dist/runtime/result/native-result.node-shim.js.map +1 -0
  10. package/dist/runtime/result/types.d.ts +55 -0
  11. package/dist/runtime/result/types.d.ts.map +1 -0
  12. package/dist/runtime/result/types.js +2 -0
  13. package/dist/runtime/result/types.js.map +1 -0
  14. package/dist/runtime/schema/core.d.ts +115 -0
  15. package/dist/runtime/schema/core.d.ts.map +1 -0
  16. package/dist/runtime/schema/core.js +259 -0
  17. package/dist/runtime/schema/core.js.map +1 -0
  18. package/dist/runtime/schema/shared.d.ts +54 -0
  19. package/dist/runtime/schema/shared.d.ts.map +1 -0
  20. package/dist/runtime/schema/shared.js +489 -0
  21. package/dist/runtime/schema/shared.js.map +1 -0
  22. package/dist/types.d.ts +7 -0
  23. package/dist/types.d.ts.map +1 -1
  24. package/include/mikrojs/cbor_helpers.h +20 -0
  25. package/include/mikrojs/mem.h +11 -0
  26. package/include/mikrojs/mikrojs.h +13 -3
  27. package/include/mikrojs/ota_client.h +339 -0
  28. package/include/mikrojs/ota_config.h +151 -0
  29. package/include/mikrojs/ota_env.h +192 -0
  30. package/include/mikrojs/ota_js_hooks.h +71 -0
  31. package/include/mikrojs/ota_policy.h +131 -0
  32. package/include/mikrojs/ota_slots.h +47 -0
  33. package/include/mikrojs/private.h +2 -2
  34. package/include/mikrojs/sys_codec.h +61 -0
  35. package/package.json +7 -5
  36. package/prebuilds/darwin-arm64/mikrojs.napi.node +0 -0
  37. package/prebuilds/linux-arm64/mikrojs.napi.node +0 -0
  38. package/prebuilds/linux-x64/mikrojs.napi.node +0 -0
  39. package/runtime/internal.d.ts +25 -16
  40. package/runtime/kv/shared.ts +11 -5
  41. package/runtime/kv/types.ts +4 -4
  42. package/runtime/ota/client.ts +12 -51
  43. package/runtime/ota/config.ts +18 -0
  44. package/runtime/ota/ota.ts +34 -70
  45. package/runtime/ota/types.ts +303 -3
  46. package/runtime/schema/core.ts +539 -0
  47. package/runtime/schema/schema.ts +36 -314
  48. package/runtime/schema/shared.ts +494 -0
  49. package/runtime/schema/types.ts +84 -12
  50. package/scripts/bundle-runtime.js +33 -0
  51. package/scripts/gen-checkin-fixtures.js +323 -0
  52. package/src/builtins.cpp +7 -8
  53. package/src/fs.cpp +3 -0
  54. package/src/mem.cpp +38 -0
  55. package/src/mik_abort.cpp +8 -1
  56. package/src/mik_cbor.cpp +43 -5
  57. package/src/mik_inspect.cpp +128 -22
  58. package/src/mik_ota_client.cpp +1163 -0
  59. package/src/mik_ota_config.cpp +433 -0
  60. package/src/mik_ota_js_hooks.cpp +190 -0
  61. package/src/mik_ota_policy.cpp +427 -0
  62. package/src/mik_ota_slots.cpp +249 -0
  63. package/src/mik_repl.cpp +9 -3
  64. package/src/mik_result.cpp +3 -1
  65. package/src/mik_sys_codec.cpp +167 -0
  66. package/src/mikrojs.cpp +24 -2
  67. package/src/modules.cpp +32 -13
  68. package/runtime/ota/client-impl.ts +0 -590
  69. package/runtime/ota/policy.ts +0 -299
@@ -1,77 +1,41 @@
1
- import {readFile} from 'mikro/fs'
2
- import {sysGet, sysSet} from 'native:mikro/nvs_kv'
3
- import * as native from 'native:mikro/ota'
1
+ // `mikro/ota` — the low-level update surface, for an app that talks to its own
2
+ // registry. The built-in client (`mikro/ota/client`) covers the ordinary case
3
+ // and does not go through here.
4
+ //
5
+ // Every member is the C policy (src/mik_ota_policy.cpp): the retry budget, the
6
+ // trial gates and the staging session all live there, so this module and the
7
+ // built-in client cannot disagree about the crash-loop latch they share.
8
+
9
+ import {config} from 'mikro/ota/config'
10
+ import {
11
+ applyConfig,
12
+ applyOffer,
13
+ bearer,
14
+ configState,
15
+ confirm,
16
+ parseConfig,
17
+ parseOffer,
18
+ reconcile,
19
+ registry,
20
+ revert,
21
+ running,
22
+ } from 'native:mikro/ota_client'
4
23
 
5
- import {createOta, type OtaStore} from './policy.js'
6
24
  import type {Ota} from './types.js'
7
25
 
8
- // Policy state, persisted to the mik.sys NVS namespace so the retry budget
9
- // survives a crash-loop and app-level nvsStorage.clear() can't wipe it.
10
- // NVS keys are capped at 15 chars.
11
- // A dropped write cannot be recovered from here, but it must not pass in
12
- // silence: `ota.tries` and `ota.inflight` are the crash-loop latch, so losing
13
- // either hands the retry budget back on every boot and the bound that stops a
14
- // panicking build from being retried forever is gone.
15
- function put(key: string, value: string | number): void {
16
- const r = sysSet(key, value)
17
- // eslint-disable-next-line no-console
18
- if (!r.ok) console.error(`ota: could not persist ${key}`, r.error)
19
- }
20
-
21
- const store: OtaStore = {
22
- getUrl: () => {
23
- const v = sysGet('ota.url')
24
- return typeof v === 'string' ? v : undefined
25
- },
26
- setUrl: (url) => put('ota.url', url),
27
- getAttempt: () => {
28
- const v = sysGet('ota.att')
29
- return typeof v === 'string' ? v : undefined
30
- },
31
- setAttempt: (checksum) => put('ota.att', checksum),
32
- getTries: () => {
33
- const v = sysGet('ota.tries')
34
- return typeof v === 'number' ? v : 0
35
- },
36
- setTries: (n) => put('ota.tries', n),
37
- getBad: () => {
38
- const v = sysGet('ota.bad')
39
- return typeof v === 'string' ? v : undefined
40
- },
41
- setBad: (checksum) => put('ota.bad', checksum),
42
- getInFlight: () => sysGet('ota.inflight') === 1,
43
- setInFlight: (value) => put('ota.inflight', value ? 1 : 0),
44
- }
45
-
46
- // Written as a pair to mik.sys by `mikro ota enroll`: the registry url and
47
- // the device update key that authenticates against it.
48
- function bearer(): string | undefined {
49
- const v = sysGet('ota.updateKey')
50
- return typeof v === 'string' ? v : undefined
51
- }
52
-
53
- function registry(): string | undefined {
54
- const v = sysGet('ota.registry')
55
- return typeof v === 'string' ? v : undefined
56
- }
57
-
58
- function readAppVersion(): string | undefined {
59
- const r = readFile('/app/package.json', 'utf-8')
60
- if (!r.ok) return undefined
61
- try {
62
- const pkg = JSON.parse(r.value) as {version?: unknown}
63
- return typeof pkg.version === 'string' ? pkg.version : undefined
64
- } catch {
65
- return undefined
66
- }
67
- }
68
-
69
- const ota: Ota = createOta({
70
- native,
71
- store,
72
- readAppVersion,
26
+ const ota: Ota = {
27
+ reconcile,
28
+ running,
29
+ parseOffer,
30
+ applyOffer,
31
+ confirm,
32
+ revert,
73
33
  bearer,
74
34
  registry,
75
- })
35
+ config,
36
+ parseConfig,
37
+ applyConfig,
38
+ configState,
39
+ }
76
40
 
77
41
  export {ota}
@@ -1,3 +1,6 @@
1
+ import type {RequestError} from 'mikro/http/helpers'
2
+
3
+ import type {CborError} from '../cbor/types.js'
1
4
  import type {Result} from '../result/types.js'
2
5
 
3
6
  /** An update offered by a registry: what to fetch and how to verify it, nothing
@@ -114,6 +117,107 @@ export type ApplyOutcome =
114
117
  * mismatch code, since it cannot tell a bad hash from bad bytes. */
115
118
  export type OtaError = OtaWriteError | OtaBeginError | OtaInstallError | OtaDownloadError
116
119
 
120
+ /** One stored config document: the complete effective config computed and
121
+ * validated by the registry (or by the CLI at cable-seed time), the opaque
122
+ * token that identifies it, and the release version it was computed for.
123
+ * The device stores and returns it without understanding it: validation is
124
+ * the writer's job, and the registry already ships the code. */
125
+ export interface StoredConfig {
126
+ /** Opaque registry-issued token echoed as `configRev` on check-ins. The
127
+ * registry serves its document whenever the echo differs from its own
128
+ * current rev, so a document it does not recognize is replaced. */
129
+ rev?: string
130
+ /** The release version this document was computed for. A document stamped
131
+ * for another version is ignored by `ota.config()`. */
132
+ version: string
133
+ /** The served document: the deviation overlay the read resolves over the
134
+ * build's manifest defaults. */
135
+ doc?: unknown
136
+ }
137
+
138
+ /** The three config slots, mirroring the build's install slots: `current` is
139
+ * what the running build reads, `next` is staged with an offered build,
140
+ * `prev` is the rollback baseline while a trial is unresolved. */
141
+ export type ConfigSlot = 'current' | 'next' | 'prev'
142
+
143
+ /** A running-release config delivery on trial. `left` is the boot budget
144
+ * (each boot that reads config burns one); `read` records that the app has
145
+ * read the document since delivery. A completed check-in adopts the trial
146
+ * only once that is true, or an app that reads config only at boot could
147
+ * have a never-executed document adopted under it. */
148
+ export interface ConfigTrial {
149
+ left: number
150
+ read: boolean
151
+ }
152
+
153
+ /** What a config write did to the store. Only `applied` and `cleared` change
154
+ * what the running build reads; `staged` changes what the next one will. */
155
+ export type ConfigWrite =
156
+ /** Stored as the running build's config. The document it replaced is kept as
157
+ * the rollback baseline and a trial is armed, so the next `ota.confirm()`
158
+ * after the app has read it is what keeps it. */
159
+ | 'applied'
160
+ /** Stored for the release the document names, which is not the one running.
161
+ * It applies when that build installs, with the build. */
162
+ | 'staged'
163
+ /** The document was removed. The manifest defaults stand alone again. */
164
+ | 'cleared'
165
+ /** Nothing moved: the document is identical to the one already held (rev
166
+ * included), or it was a clear with nothing to clear. */
167
+ | 'unchanged'
168
+ /** Nothing was written, and the reason is transient: the store could not
169
+ * answer, or the running version could not be read. Keep echoing the rev
170
+ * from `configState()` so the document is served again, rather than the rev
171
+ * of the document that did not land. */
172
+ | 'failed'
173
+ /** Not a usable config document. Validate with `parseConfig` first to find
174
+ * out before the delivery, and log what the wire actually carried. */
175
+ | 'invalid'
176
+
177
+ /** What a check-in body owes the registry about config, for a client that
178
+ * builds its own. */
179
+ export interface ConfigState {
180
+ /** The rev to send as `configRev`: the registry serves its document whenever
181
+ * this differs from its own current rev. Absent when the device holds no
182
+ * document. After a rolled-back trial this is the FAILED document's rev,
183
+ * which is what stops the registry serving it again. */
184
+ rev?: string
185
+ /** A document that failed its trial and was rolled back, reported until it
186
+ * is replaced. Send it as `configError`, or an operator has no way to see
187
+ * that the document they published took the device down. */
188
+ error?: ConfigErrorReport
189
+ }
190
+
191
+ /** A config document rolled back after a failed trial; reported on check-ins
192
+ * while it stands. `rev` names the failed document, and the client keeps
193
+ * echoing it as `configRev`, which is what stops the registry re-serving
194
+ * the same document until an operator changes the config. */
195
+ export interface ConfigErrorReport {
196
+ rev: string
197
+ message: string
198
+ }
199
+
200
+ declare global {
201
+ /**
202
+ * Merge your app's config type into this interface (next to the schema
203
+ * definition) to type `ota.config()` app-wide, with no type parameter at
204
+ * the call sites:
205
+ *
206
+ * ```ts
207
+ * declare global {
208
+ * interface OtaConfig extends InferRead<typeof ConfigSchema> {}
209
+ * }
210
+ * ```
211
+ *
212
+ * A global rather than a module augmentation because `mikro/ota` re-exports
213
+ * its types, and module augmentation does not merge through re-exports. An
214
+ * explicit `ota.config<T>()` still works and wins over the registration.
215
+ */
216
+ interface OtaConfig {}
217
+ }
218
+
219
+ export type RegisteredConfig = keyof OtaConfig extends never ? unknown : OtaConfig
220
+
117
221
  export interface Ota {
118
222
  /** Report what happened to a previous update on this boot, and clear the report. */
119
223
  reconcile(): InstallOutcome
@@ -122,14 +226,20 @@ export interface Ota {
122
226
  /** Validate a registry value into an `Offer`, or `undefined` if unusable.
123
227
  * `allowInsecure` (dev only) accepts an http build url instead of https. */
124
228
  parseOffer(raw: unknown, opts?: {allowInsecure?: boolean}): Offer | undefined
125
- /** Run the full update policy: skip checks, compatibility, retry limit,
126
- * download via the `download` callback, and verification. */
229
+ /** Run the full update policy: skip checks and the retry limit, download
230
+ * via the `download` callback, and verification. Compatibility is not
231
+ * re-checked: the registry selected this build for the reported firmware,
232
+ * and a mismatched archive fails its checksum or fails to load. */
127
233
  applyOffer(
128
234
  offer: Offer,
129
235
  download: DownloadFn,
130
236
  options?: InstallOptions,
131
237
  ): Promise<Result<ApplyOutcome, OtaError>>
132
- /** Mark the running trial as healthy so it is kept rather than rolled back. */
238
+ /** Mark the running trial as healthy so it is kept rather than rolled back.
239
+ * Settles a delivered config document's trial by the same call: a completed
240
+ * check-in is the health signal both of them wait for. A config trial waits
241
+ * additionally for the app to have read the document, so a `confirm()` from
242
+ * an app that never called `config()` keeps nothing. */
133
243
  confirm(): void
134
244
  /** Reinstall the previous build immediately. */
135
245
  revert(): Result<void, OtaInstallError>
@@ -140,8 +250,198 @@ export interface Ota {
140
250
  /** The registry url the device was enrolled against, written next to the
141
251
  * update key at enrollment, or `undefined` on an un-enrolled device. */
142
252
  registry(): string | undefined
253
+ /**
254
+ * The app's effective config: the running build's manifest defaults with the
255
+ * document the registry computed for this release spread over them, top level
256
+ * only. Always an object for a build that went through the tooling, so no
257
+ * `?? fallback` at the call sites; fields the schema gives no default are the
258
+ * ones that can be absent, and the read type marks exactly those optional.
259
+ *
260
+ * Reads current state on every call: it changes exactly when a check-in
261
+ * completes, and every call hands back a fresh object, so mutating one never
262
+ * reaches the cached defaults. The one thing held between calls is the last
263
+ * document that read successfully, which is served while, and only while, the
264
+ * store cannot answer. A read fails under heap pressure, and flipping a
265
+ * running app onto the defaults for a beat would re-configure its hardware
266
+ * mid-handshake. A cleared document removes the key and reads back as an
267
+ * honest absence, so a clear is never mistaken for a failure.
268
+ *
269
+ * The device never merges deeper than one level and never validates: every
270
+ * writer of the document validated it against this release's schema before
271
+ * writing, and the type comes from the same source definition
272
+ * (`ota.config<Config>()` with the schema's `InferRead`).
273
+ *
274
+ * Throws when there is nothing to serve at all: a build carrying no readable
275
+ * manifest and no stored document (deploy it with `mikro deploy`, or run
276
+ * `mikro dev`), or a store that failed before any read succeeded this runtime
277
+ * on a build whose manifest could not be parsed either. Both are transient or
278
+ * fixable states, never a value the app has to branch on.
279
+ */
280
+ config<T = RegisteredConfig>(): T
281
+ /**
282
+ * Validate a config document a client received over its own transport, or
283
+ * `undefined` when it cannot be used. What `parseOffer` is to an offer.
284
+ *
285
+ * A usable document is an object with a non-empty `version` (the release it
286
+ * was computed for, which decides where `applyConfig` puts it), an optional
287
+ * `rev` short enough to echo intact, and a `doc` that is an object or absent.
288
+ * An absent `doc` is the clear, not a malformed document.
289
+ *
290
+ * Whether the document survives CBOR is settled by `applyConfig`, which is
291
+ * where the stored bytes are made.
292
+ */
293
+ parseConfig(raw: unknown): StoredConfig | undefined
294
+ /**
295
+ * Store a config document, for a client that received one over its own
296
+ * transport. The built-in client covers the ordinary case and does not go
297
+ * through here.
298
+ *
299
+ * The `version` stamp decides where it lands: stamped for the running
300
+ * release it is applied, and the document it replaces is kept as the
301
+ * rollback baseline; stamped for another it is staged for the build it
302
+ * names, to apply when that build installs. The return value says which
303
+ * happened, and says when nothing did.
304
+ *
305
+ * A delivery to the running release arms a trial. Each boot whose first
306
+ * `config()` read serves the document burns one of `trialBoots`, and the
307
+ * budget spent with no `confirm()` in between restores the previous
308
+ * document. On a wake-cycle device every wake is a boot, so raise
309
+ * `trialBoots` above the default when a check-in can plausibly fail a few
310
+ * cycles in a row.
311
+ */
312
+ applyConfig(config: StoredConfig, options?: {trialBoots?: number}): ConfigWrite
313
+ /**
314
+ * What the device owes its registry about config: the rev to echo, and a
315
+ * rolled-back document to report. A client that builds its own check-in body
316
+ * needs both. Without the echo the registry re-serves the same document on
317
+ * every check-in; without the report a document that took the device down is
318
+ * re-served forever and nobody is told.
319
+ */
320
+ configState(): ConfigState
143
321
  }
144
322
 
145
323
  /** The `mikro/ota` singleton. The runtime value is provided by the on-device
146
324
  * builtin (or the sim stub); this declaration carries its type for hosts. */
147
325
  export declare const ota: Ota
326
+
327
+ /* ── mikro/ota/client ──────────────────────────────────────────────────────
328
+ * The check-in client's surface. The implementation is C
329
+ * (src/mik_ota_client.cpp); these are the shapes it marshals across. */
330
+
331
+ export interface CheckOptions {
332
+ /** Budget for the check-in round trip. Default 10s. */
333
+ checkinTimeoutMs?: number
334
+ /** Budget for the build download. Separate from the check-in's because it is
335
+ * a total wallclock deadline that cancels the transfer mid-stream, and an
336
+ * image needs orders of magnitude more of it than a check-in body does.
337
+ * Default 5m. */
338
+ downloadTimeoutMs?: number
339
+ /** Require a completed check-in (via `ota.confirm()`, which the client fires
340
+ * itself) before an installed build is kept. Default true. */
341
+ requireConfirm?: boolean
342
+ /** Clean boots a trial may consume before an unconfirmed build reverts.
343
+ * A deep-sleep wake counts as a clean boot, so wake-cycle devices on flaky
344
+ * networks should raise this above the default 1. The same budget arms a
345
+ * delivered config document's trial. */
346
+ trialBoots?: number
347
+ }
348
+
349
+ /** Runs after its round settles: after the check and any download, and before
350
+ * an auto-restart, so the network brought up in `beforeCheck` can go down.
351
+ *
352
+ * Whatever it returns is ignored, so it can call something that reports a
353
+ * Result without having to unwrap or discard it. A promise is awaited before
354
+ * the round is considered over. */
355
+ export type Teardown = () => unknown
356
+
357
+ /** What a `beforeCheck` hands back:
358
+ *
359
+ * - a **function**: the round runs, and that function runs after it
360
+ * - **nothing**: the round runs, with no teardown
361
+ * - an **`err`**: the round is skipped and retried at the failure interval,
362
+ * and no teardown runs, since unwinding a partial setup is the hook's own job
363
+ * - an **`ok`**: as its value, a teardown function or nothing
364
+ *
365
+ * Throwing has the same effect as returning an `err`. The bare-function form
366
+ * is there because wrapping a teardown in `ok()` is ceremony on the path that
367
+ * always succeeds; the Result form is what lets a failing setup hand its own
368
+ * error straight back. */
369
+ export type BeforeCheckResult = Teardown | void | Result<Teardown | void, unknown>
370
+
371
+ export interface WatchOptions extends CheckOptions {
372
+ /** Steady interval between rounds, end-of-round to start-of-next. Default
373
+ * 30m, floored at 30s: each round's TLS session leaves heap and socket
374
+ * residue that needs time to drain on small-heap devices, and the value
375
+ * may arrive from remote config, and the floor is what bounds the damage a
376
+ * mistyped document can do. */
377
+ checkinIntervalMs?: number
378
+ /** Delay before the first round. Default 5s. */
379
+ initialDelayMs?: number
380
+ /** Interval after a failed round, capped at `checkinIntervalMs`. Default 1m. */
381
+ retryAfterFailureMs?: number
382
+ /** Spread every scheduled sleep by ±10%, so a fleet that lost power together
383
+ * does not check in phase-locked forever. Default true; pass false for
384
+ * exact intervals (a demo watching for the update to land, a single
385
+ * device where the spread only delays it). */
386
+ jitter?: boolean
387
+ /** Bring the network up for one round. State shared with the teardown stays
388
+ * in this one scope. See {@link BeforeCheckResult} for what to hand back. */
389
+ beforeCheck?(): BeforeCheckResult | Promise<BeforeCheckResult>
390
+ /** Called after a completed round changes the effective config the running
391
+ * build reads: delivered or cleared by that round, or applied by this boot's
392
+ * install or rollback. Receives the new effective config. A watch loop is
393
+ * otherwise silent, so without this an app has to poll `ota.config()` to
394
+ * notice.
395
+ *
396
+ * Not called for a config staged alongside an offered build: that one
397
+ * applies at its trial boot, so the running app cannot read it yet. */
398
+ onConfig?(config: RegisteredConfig): void
399
+ }
400
+
401
+ export interface Watcher {
402
+ /** Prevent future rounds and cancel the pending sleep. An in-flight round
403
+ * completes, but a build it stages no longer auto-restarts: it stays armed
404
+ * for the next natural reboot. */
405
+ stop(): void
406
+ /** Change the cadence without restarting the watcher, floored at 30s like
407
+ * {@link WatchOptions.checkinIntervalMs}. A wait already counting is re-timed
408
+ * from when it started, so a shorter interval brings the next round forward
409
+ * rather than waiting out the old one. The initial delay is left alone. */
410
+ setCheckinInterval(intervalMs: number): void
411
+ }
412
+
413
+ /** Why an offered build was not armed. Each is the policy working as intended;
414
+ * the distinction is logged and returned because the app's next move differs. */
415
+ export type DeclineReason =
416
+ | 'trial-pending'
417
+ | 'current'
418
+ | 'abandoned'
419
+ | 'exhausted'
420
+ | 'download-failed'
421
+ | 'install-failed'
422
+
423
+ /** The check-in never completed. */
424
+ export type CheckError =
425
+ | RequestError
426
+ | CborError
427
+ | {name: 'Status'; status: number}
428
+ /** The response body was past the size the client will buffer. */
429
+ | {name: 'TooLarge'; message: string}
430
+
431
+ export type CheckResult =
432
+ /** Build downloaded, verified, and armed. The app restarts when ready. */
433
+ | {status: 'staged'; offer: Offer}
434
+ /** `configUpdated` reports that the running build's stored config changed
435
+ * since the app could last have read it: delivered or cleared this round,
436
+ * or applied by this boot's install or rollback. An app that read config
437
+ * early in the cycle knows to read it again. */
438
+ | {status: 'up-to-date'; configUpdated?: boolean}
439
+ /** An offer arrived but was not armed. */
440
+ | {status: 'not-staged'; reason: DeclineReason; error?: OtaError}
441
+ /** Transient: the check-in did not complete, so the running trial (if any)
442
+ * was not confirmed. */
443
+ | {status: 'failed'; error: CheckError}
444
+ /** The registry rejected the update key (HTTP 401). Permanent until
445
+ * re-enrollment over the cable. */
446
+ | {status: 'unauthorized'}
447
+ | {status: 'not-enrolled'}