@fleetless/contracts 1.0.0 → 1.0.2

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 (48) hide show
  1. package/CHANGELOG.md +68 -2
  2. package/CODE_OF_CONDUCT.md +83 -0
  3. package/CONTRIBUTING.md +136 -0
  4. package/README.md +49 -13
  5. package/SECURITY.md +55 -0
  6. package/artifacts/openapi.json +1 -1
  7. package/artifacts/routes.json +1 -1
  8. package/dist/alerts.d.ts +19 -24
  9. package/dist/alerts.js +18 -24
  10. package/dist/app-users.d.ts +7 -6
  11. package/dist/app-users.js +6 -6
  12. package/dist/apps.d.ts +21 -25
  13. package/dist/apps.js +40 -51
  14. package/dist/assets.d.ts +70 -132
  15. package/dist/assets.js +130 -223
  16. package/dist/audit.d.ts +11 -11
  17. package/dist/audit.js +25 -51
  18. package/dist/client-auth.d.ts +4 -4
  19. package/dist/client-auth.js +3 -4
  20. package/dist/common.d.ts +27 -35
  21. package/dist/common.js +26 -35
  22. package/dist/config-issues.d.ts +4 -3
  23. package/dist/config-issues.js +7 -6
  24. package/dist/config.d.ts +31 -37
  25. package/dist/config.js +81 -110
  26. package/dist/errors.d.ts +4 -3
  27. package/dist/errors.js +61 -87
  28. package/dist/identity.d.ts +18 -21
  29. package/dist/identity.js +17 -21
  30. package/dist/index.d.ts +4 -4
  31. package/dist/index.js +12 -13
  32. package/dist/introspection.d.ts +7 -6
  33. package/dist/introspection.js +6 -6
  34. package/dist/jobs.d.ts +12 -12
  35. package/dist/jobs.js +20 -25
  36. package/dist/mcp.d.ts +11 -12
  37. package/dist/mcp.js +10 -12
  38. package/dist/oauth.d.ts +13 -18
  39. package/dist/oauth.js +13 -19
  40. package/dist/protocol.d.ts +51 -62
  41. package/dist/protocol.js +107 -139
  42. package/dist/realtime.d.ts +53 -68
  43. package/dist/realtime.js +77 -103
  44. package/dist/rest.d.ts +182 -243
  45. package/dist/rest.js +301 -395
  46. package/dist/routes.d.ts +4 -3
  47. package/dist/routes.js +3 -2
  48. package/package.json +12 -7
package/dist/protocol.js CHANGED
@@ -10,9 +10,8 @@ import { rosTypeName } from './common.js';
10
10
  * Bridge <-> cloud protocol, version 2.
11
11
  *
12
12
  * The version is exchanged in the hello handshake; the cloud refuses an
13
- * incompatible bridge with a clear message (spec §5) — `ws/bridge.ts`'s
14
- * `protocol_mismatch`, which names both versions and lands on the robot
15
- * detail page as `last_hello_error`.
13
+ * incompatible bridge with a clear message: `protocol_mismatch`, which names
14
+ * both versions and reaches the robot's detail view as `last_hello_error`.
16
15
  *
17
16
  * **2 (2026-08-21):** `config_applied.errors` entries gained `kind` and `code`
18
17
  * beside `message`. The check is `!==`, not a floor, so a bridge that is not
@@ -21,7 +20,7 @@ import { rosTypeName } from './common.js';
21
20
  */
22
21
  export const PROTOCOL_VERSION = 2;
23
22
  /**
24
- * The bridge socket close code for "this robot no longer exists" (W6a).
23
+ * The bridge socket close code for "this robot no longer exists".
25
24
  *
26
25
  * Deliberately distinct from the auth failures: a deleted robot must **stop**,
27
26
  * and a token that was valid a second ago is indistinguishable from one that
@@ -33,42 +32,37 @@ export const PROTOCOL_VERSION = 2;
33
32
  export const CLOSE_ROBOT_DELETED = 4004;
34
33
  /**
35
34
  * How long a command waits for its answer when the caller names no patience
36
- * of its own (W6b).
35
+ * of its own.
37
36
  *
38
- * 15 s, which is what both halves already used independently: the cloud's
39
- * `commandTimeoutMs` and the bridge's `GOAL_ACCEPT_TIMEOUT_S`. That they
40
- * agreed was a coincidence of two separate decisions, and neither side could
41
- * be told otherwise for a single call. Naming the number once, here, is what
42
- * makes it one number rather than two that happen to match.
37
+ * 15 s, stated once here rather than once in the cloud and once in the bridge.
38
+ * Two constants that happen to match are not one number: neither side can be
39
+ * told otherwise for a single call, and when they drift nobody can say whose
40
+ * deadline a caller hit.
43
41
  *
44
- * A caller who knows their robot's work takes longer says so per call. A
45
- * caller who says nothing gets exactly today's behaviour — which is the point
46
- * of picking today's number as the default rather than a nicer one.
42
+ * A caller who knows their robot's work takes longer says so per call.
47
43
  */
48
44
  export const DEFAULT_PATIENCE_MS = 15_000;
49
45
  /**
50
46
  * The longest patience a caller may ask for.
51
47
  *
52
- * A waiting REST request is a held-open connection, and there is **no rate
53
- * limiting** on this platform until W8 — so an unbounded `patience_ms` is an
54
- * unauthenticated way to pin the cloud's sockets open. Two minutes is long
55
- * enough for the robot work anybody has described (a planner, a docking
48
+ * A waiting REST request is a held-open connection, so an unbounded
49
+ * `patience_ms` is a way to pin the cloud's sockets open. Two minutes is long
50
+ * enough for the robot work this API is meant for (a planner, a docking
56
51
  * manoeuvre, an arm trajectory) and short enough that a thousand of them is
57
52
  * still a bounded amount of cloud.
58
53
  *
59
- * Raising it is a W8 conversation, after rate limiting exists — not a
60
- * one-line change here.
54
+ * Raising it is a conversation about rate limiting, not a one-line change
55
+ * here.
61
56
  */
62
57
  export const MAX_PATIENCE_MS = 120_000;
63
58
  /**
64
59
  * The shortest patience a caller may ask for.
65
60
  *
66
- * A floor exists because **impatience reaches the robot**. Measured in W6b's
67
- * review: `patience_ms: 1` on an action makes the bridge report `goal_timeout`
68
- * and then issue a *corrective cancel* against a goal the action server
69
- * accepts a moment later — so a caller who asks for an unreachable deadline
70
- * does not merely get an error, they cause a cancellation on the machine.
71
- * Repeatable, and on a platform with no rate limiting until W8.
61
+ * A floor exists because **impatience reaches the robot**. A `patience_ms` of
62
+ * 1 on an action makes the bridge report `goal_timeout` and then issue a
63
+ * *corrective cancel* against a goal the action server accepts a moment later
64
+ * — so a caller who asks for an unreachable deadline does not merely get an
65
+ * error, they cause a cancellation on the machine.
72
66
  *
73
67
  * One second, because it has to be longer than a goal-acceptance round trip on
74
68
  * a healthy robot and shorter than any wait a human would call patient. It is
@@ -80,7 +74,7 @@ export const MIN_PATIENCE_MS = 1_000;
80
74
  /** Re-exported so consumers keep importing wire names from one place. */
81
75
  export { slug } from './common.js';
82
76
  /**
83
- * One job the bridge still has, as reported in the handshake (W6b).
77
+ * One job the bridge still has, as reported in the handshake.
84
78
  *
85
79
  * It carries the **slug and the state**, not only the id, because the cloud's
86
80
  * reconciliation needs both and had neither. Reading `active_job_ids` as bare
@@ -107,7 +101,7 @@ export const bridgeHello = z.object({
107
101
  token: z.string().min(1),
108
102
  bridge_version: z.string().min(1),
109
103
  /**
110
- * Every job this bridge still knows about, right now (spec §6.1, W4).
104
+ * Every job this bridge still knows about, right now.
111
105
  *
112
106
  * A reconnect and a restart look **identical** on the wire otherwise: same
113
107
  * token, same version, same frame. But they must end differently — after a
@@ -122,16 +116,9 @@ export const bridgeHello = z.object({
122
116
  * has none — which is exactly the truth the cloud needs. A breadcrumb file
123
117
  * would only add a window in which the crash beat the write.
124
118
  *
125
- * Defaulted so pre-W4 bridges still parse; they had no jobs, so the empty
126
- * list is also the correct answer for them.
127
- *
128
- * **Renamed from `active_job_ids` in W6b**, when the entries stopped being
129
- * ids. A field called `_ids` holding objects is the shape this project has
130
- * repeatedly been caught by — a name that describes what the field used to
131
- * carry, kept because renaming looked like churn. Nothing is deployed yet
132
- * (W8 is the first deployment), so the old name is gone rather than
133
- * accepted alongside the new one: two accepted spellings would have to be
134
- * supported and reconciled forever, and nobody is asking for that.
119
+ * Defaulted, so a bridge that sends no such field still parses; a bridge
120
+ * with no jobs and a bridge that does not report them both mean the cloud
121
+ * has nothing to keep alive.
135
122
  */
136
123
  active_jobs: z.array(activeJob).max(500).default([]),
137
124
  });
@@ -148,7 +135,7 @@ export const cloudHelloError = z.object({
148
135
  });
149
136
  /**
150
137
  * One datapoint sample. `timestamp_ms` is the capture time at the bridge —
151
- * never the receive time — so clients compute age themselves (spec §6.3).
138
+ * never the receive time — so clients compute age themselves.
152
139
  */
153
140
  export const datapointFrame = z.object({
154
141
  type: z.literal('datapoint'),
@@ -171,9 +158,9 @@ export const bridgePong = z.object({
171
158
  ts_ms: z.number().int().nonnegative(),
172
159
  });
173
160
  /**
174
- * The published configuration, cloud → bridge (spec §4.1: the bridge applies
175
- * the published version). Sent right after `hello_ok` and again on every
176
- * publish, so a bridge never has to ask.
161
+ * The published configuration, cloud → bridge — the bridge applies the
162
+ * published version. Sent right after `hello_ok` and again on every publish,
163
+ * so a bridge never has to ask.
177
164
  *
178
165
  * `version: 0` with an empty document means *nothing published yet* — a fresh
179
166
  * robot, not an error.
@@ -214,7 +201,7 @@ export const bridgeConfigApplied = z.object({
214
201
  errors: z.array(applyError),
215
202
  });
216
203
  /**
217
- * Commands, cloud → bridge (spec §6.1, §11.3). The **cloud** mints the
204
+ * Commands, cloud → bridge. The **cloud** mints the
218
205
  * `job_id` before the bridge is asked to do anything, so a job exists —
219
206
  * and can be reported `lost` — even if the answer never comes back.
220
207
  */
@@ -223,21 +210,20 @@ export const cloudInvoke = z.object({
223
210
  job_id: z.uuid(),
224
211
  slug,
225
212
  /**
226
- * Already validated against §4.4 rules; the bridge validates structurally.
213
+ * Already validated against the configuration's parameter rules; the bridge
214
+ * validates structurally.
227
215
  *
228
216
  * **Flat, keyed by parameter name** — `{"target_x": 1}`. The key is a key of
229
- * the entry's `parameters` mapping, not a path into the message. Those were
230
- * the same thing until FL-002 and are now deliberately decoupled: a
231
- * parameter keeps its name when the field it fills moves in the message
232
- * tree, which is the same reason a slug is not a topic name.
217
+ * the entry's `parameters` mapping, not a path into the message. The two are
218
+ * deliberately decoupled: a parameter keeps its name when the field it fills
219
+ * moves in the message tree, which is the same reason a slug is not a topic
220
+ * name.
233
221
  *
234
- * Three things follow, and the last one got stronger rather than weaker:
235
- * the key a caller sends is the key a rule names, so a `parameter_invalid`
236
- * reports something the caller can find; the console binds one input per
237
- * parameter; and a position the template does not mark with `${…}` cannot
238
- * be set by any caller at all. That last one used to be a rule about what
239
- * no `parameterSpec` declared. It is now structural — the value has nowhere
240
- * to go.
222
+ * Three things follow: the key a caller sends is the key a rule names, so a
223
+ * `parameter_invalid` reports something the caller can find; a UI binds one
224
+ * input per parameter; and a position the template does not mark with
225
+ * `${…}` cannot be set by any caller at all, structurally — the value has
226
+ * nowhere to go.
241
227
  *
242
228
  * The bridge substitutes these values into the entry's `message` template
243
229
  * at its placeholder positions. It no longer unflattens a dotted path;
@@ -245,29 +231,28 @@ export const cloudInvoke = z.object({
245
231
  */
246
232
  params: z.record(z.string(), z.unknown()),
247
233
  /**
248
- * How long this one call is worth waiting for (W6b), already resolved by
249
- * the cloud — the caller's `invokeRequest.patience_ms`, or
234
+ * How long this one call is worth waiting for, already resolved by the
235
+ * cloud — the caller's `invokeRequest.patience_ms`, or
250
236
  * `DEFAULT_PATIENCE_MS` when they named none.
251
237
  *
252
238
  * **Required here, optional at REST**, deliberately. At the REST edge an
253
239
  * absent value is a caller who did not care and gets the default. By the
254
240
  * time the frame is on this socket somebody has decided, and the bridge
255
241
  * must never be in the position of picking a number the cloud is already
256
- * counting against — which is what two independent 15 s constants meant in
257
- * practice: a bridge that gave up at 15.0 s and a cloud that gave up at
258
- * 15.0 s, agreeing only by accident, with no way to tell whose deadline a
259
- * caller had actually hit.
242
+ * counting against. Two independent constants that happen to match give up
243
+ * at the same moment by accident, with no way to tell whose deadline a
244
+ * caller actually hit.
260
245
  */
261
246
  patience_ms: z.number().int().min(MIN_PATIENCE_MS).max(MAX_PATIENCE_MS),
262
247
  });
263
248
  /**
264
- * Cancel — the bridge must issue a real ROS goal cancel (§11.3).
249
+ * Cancel — the bridge must issue a real ROS goal cancel.
265
250
  *
266
251
  * `slug` stays, and stays required: it is how the bridge finds the tracker,
267
252
  * and it is what a cancel with no id means.
268
253
  *
269
- * `job_id` is what W6b adds, and what makes a cancel say *which* job. Without
270
- * it a cancel arriving a moment after one job ended and another began on the
254
+ * `job_id` is what makes a cancel say *which* job. Without it a cancel
255
+ * arriving a moment after one job ended and another began on the
271
256
  * same slug stops the **new** one — the caller asked to stop something that
272
257
  * had already finished and stopped a machine that had just started moving.
273
258
  * That is not a race anybody had to lose: the caller knew the id, and the
@@ -302,7 +287,7 @@ export const cloudPublish = z.object({
302
287
  });
303
288
  /**
304
289
  * Progress on a job, bridge → cloud. `timestamp_ms` is capture time, so a
305
- * burst delivered late after a reconnect is visibly late (§6.3).
290
+ * burst delivered late after a reconnect is visibly late.
306
291
  */
307
292
  export const bridgeJobUpdate = z.object({
308
293
  type: z.literal('job_update'),
@@ -319,7 +304,7 @@ export const bridgeJobUpdate = z.object({
319
304
  timestamp_ms: z.number().int().nonnegative(),
320
305
  });
321
306
  /**
322
- * Jobs the bridge can no longer account for **while connected** (§6.1) — a
307
+ * Jobs the bridge can no longer account for **while connected** — a
323
308
  * tracker dropped, an action server that vanished mid-goal, anything where
324
309
  * the honest answer is "I lost this" rather than a state.
325
310
  *
@@ -365,8 +350,8 @@ export const bridgeTypeDefinitions = z.object({
365
350
  unresolved: z.array(z.string()),
366
351
  });
367
352
  /**
368
- * The built-in `bridge_state` datapoint every robot has (spec §4.3):
369
- * connection status plus latency, the basis for offline-aware client UIs.
353
+ * The built-in `bridge_state` datapoint every robot has: connection status
354
+ * plus latency, the basis for offline-aware client UIs.
370
355
  */
371
356
  export const bridgeState = z.object({
372
357
  online: z.boolean(),
@@ -380,8 +365,7 @@ const bridgePressureTier = z.object({
380
365
  high_water: z.number().int().nonnegative(),
381
366
  });
382
367
  /**
383
- * The built-in `bridge_pressure` datapoint (spec §4.3, the pressure-telemetry
384
- * design's "The decision that shapes everything"): the bridge's own
368
+ * The built-in `bridge_pressure` datapoint: the bridge's own
385
369
  * bandwidth-shaping state, sent on the same reserved-slug path as
386
370
  * `bridge_state` so history, realtime, REST and MCP exposure fall out of the
387
371
  * ordinary datapoint machinery for free.
@@ -451,8 +435,8 @@ export const bridgePressure = z.object({
451
435
  }),
452
436
  });
453
437
  export const PRESSURE_SLUG = 'bridge_pressure';
454
- /* ------------------------------------------------------------------ W5 --
455
- * Cameras (spec §10).
438
+ /* ------------------------------------------------------------------------
439
+ * Cameras.
456
440
  */
457
441
  /**
458
442
  * The header of a **binary** snapshot frame, bridge → cloud.
@@ -464,13 +448,11 @@ export const PRESSURE_SLUG = 'bridge_pressure';
464
448
  * Binary rather than base64 in a text frame, because base64 costs a third of
465
449
  * the robot's upstream for nothing. Self-contained rather than a JSON frame
466
450
  * followed by a binary one, because that pairing would depend on frame
467
- * ordering — and W4 established, at some cost, that ordering across a socket
468
- * is not something to lean on.
451
+ * ordering, and ordering across a socket is not something to lean on.
469
452
  *
470
- * `timestamp_ms` is the bridge's **capture** time (§6.3), which is what lets
471
- * every consumer state a snapshot's true age. A picture that lies about when
472
- * it was taken is this wave's version of a job that reads "running" when
473
- * nobody knows.
453
+ * `timestamp_ms` is the bridge's **capture** time, which is what lets every
454
+ * consumer state a snapshot's true age. A picture that lies about when it was
455
+ * taken is as bad as a job that reads "running" when nobody knows.
474
456
  */
475
457
  /**
476
458
  * The largest a snapshot frame — header and image bytes together — may be on
@@ -486,15 +468,13 @@ export const PRESSURE_SLUG = 'bridge_pressure';
486
468
  * connection with 1009 — taking datapoints, jobs, commands and configuration
487
469
  * down with it. The bridge would then reconnect, receive the same
488
470
  * configuration, capture the same frame and be closed again: a robot that
489
- * will not stay online, from a configuration the platform accepted. Measured
490
- * during the W5 review, a 4K JPEG of real camera content lands around
491
- * 2.2 MiB and 1080p on a noisy scene within 40% of this number, so the margin
492
- * is thinner than it looks.
471
+ * will not stay online, from a configuration the platform accepted. The margin
472
+ * is thinner than it looks — a 4K JPEG of real camera content lands around
473
+ * 2.2 MiB, and 1080p on a noisy scene comes within half of this number.
493
474
  *
494
- * **The bridge must degrade rather than exceed it** — lower JPEG quality,
495
- * then downscale, and if it still does not fit, skip the frame and say so.
496
- * A missing snapshot is a gap, and this wave already established that a gap
497
- * is an honest answer; a closed socket is not.
475
+ * **The bridge must degrade rather than exceed it** — lower JPEG quality, then
476
+ * downscale, and if it still does not fit, skip the frame and say so. A missing
477
+ * snapshot is a gap, and a gap is an honest answer; a closed socket is not.
498
478
  */
499
479
  export const SNAPSHOT_MAX_BYTES = 1_572_864; // 1.5 MiB, against a 2 MiB socket ceiling
500
480
  export const snapshotHeader = z.object({
@@ -510,7 +490,7 @@ export const snapshotHeader = z.object({
510
490
  * Cloud → bridge: start publishing this camera live.
511
491
  *
512
492
  * The **cloud** mints the room and the publisher token, for the same reason
513
- * it mints a `job_id` before asking anything (§6.1): the side that owns the
493
+ * it mints a `job_id` before asking anything: the side that owns the
514
494
  * refcount must own the identity of the stream, or a robot could end up
515
495
  * publishing into a room nobody is watching.
516
496
  */
@@ -521,26 +501,25 @@ export const cloudCameraStart = z.object({
521
501
  room: z.string().min(1),
522
502
  token: z.string().min(1),
523
503
  /**
524
- * Names **this attempt** (W6b), and is echoed in the `camera_state` that
525
- * answers it.
504
+ * Names **this attempt**, and is echoed in the `camera_state` that answers
505
+ * it.
526
506
  *
527
- * W6a gave `camera_state` a `cause` and said in the same comment that a
528
- * cause is not a correlation. This is the other half. Start a camera, have
529
- * it fail slowly, start it again: the first attempt's failure arrives while
507
+ * `camera_state.cause` says what kind of event a frame is; a cause is not a
508
+ * correlation, and this is the other half. Start a camera, have it fail
509
+ * slowly, start it again: the first attempt's failure arrives while
530
510
  * the second is in flight, matches on slug, and resolves the attempt it
531
511
  * knows nothing about. The viewer is then told the running stream failed,
532
512
  * for a reason belonging to an attempt that is already over.
533
513
  */
534
514
  request_id: z.string().min(1).max(64),
535
515
  });
536
- /** Cloud → bridge: the last viewer left; stop publishing (§10 refcount). */
516
+ /** Cloud → bridge: the last viewer left; stop publishing. */
537
517
  /**
538
- * Assets (spec §4.6, W7): the bridge **reports availability and transfers
539
- * nothing** until asked.
518
+ * Assets: the bridge **reports availability and transfers nothing** until
519
+ * asked.
540
520
  *
541
- * **The bytes never travel on this socket.** `server.ts` caps a frame at
542
- * 2 MiB, a single mesh exceeds that routinely, and raising the cap is already
543
- * tied to W8's rate limiting in the deferral register because it amplifies an
521
+ * **The bytes never travel on this socket.** A frame is capped at 2 MiB and a
522
+ * single mesh exceeds that routinely; raising the cap amplifies an
544
523
  * unauthenticated path. So the socket carries the *conversation* — what exists,
545
524
  * transfer this, here is how far I got — and the bytes go over HTTP with the
546
525
  * robot's own credential.
@@ -562,7 +541,7 @@ export const bridgeAssetsAvailable = z.object({
562
541
  meshes: z.array(z.string().min(1)),
563
542
  });
564
543
  /**
565
- * The explicit request §4.6 requires — nothing moves without it.
544
+ * The explicit request that starts a transfer — nothing moves without it.
566
545
  *
567
546
  * The upload credential is minted per sync and travels here rather than being
568
547
  * derived from the robot token: it is scoped to one robot's assets and one
@@ -591,12 +570,13 @@ export const bridgeAssetProgress = z.object({
591
570
  total: z.number().int().nonnegative(),
592
571
  /**
593
572
  * **Each entry says why** — see `assetFailure` in `assets.ts` for the three
594
- * kinds and why one word was not enough. The bound is `assets.ts`'s too: a
595
- * `.dae` with 17,331 unresolvable internal references produced a frame 32
596
- * bytes over `MAX_WS_PAYLOAD_BYTES`, and `ws` enforces that **before**
597
- * delivery — so the outcome was the robot's own socket closed, mid-sync, by
598
- * a file in its workspace (Kassandra-W7a). A producer at its own ceiling
599
- * reports **one** `refused` entry naming the file, not one per reference.
573
+ * kinds and why one word is not enough. The bound is `assets.ts`'s too: a
574
+ * single `.dae` can carry tens of thousands of unresolvable internal
575
+ * references, which is enough to push this frame past
576
+ * `MAX_WS_PAYLOAD_BYTES`. That limit is enforced **before** delivery, so the
577
+ * outcome is not a dropped frame but the robot's own socket closed mid-sync
578
+ * by a file in its workspace. A producer at its own ceiling reports **one**
579
+ * `refused` entry naming the file, not one per reference.
600
580
  */
601
581
  failed: z.array(assetFailure).max(1000),
602
582
  /**
@@ -608,17 +588,12 @@ export const bridgeAssetProgress = z.object({
608
588
  * answers with silence is a backstop nobody can debug, and the alternative
609
589
  * on the table was to report every requested URI in `failed`. That would
610
590
  * have made `failed` mean two different things at once — *could not be
611
- * resolved* and *was never attempted* — which is the one-field-two-facts
612
- * defect this project has now split five times (`set`/`readable`,
613
- * `truncated`/`truncated_by`, `value`/`sample_count`, `publishing`/`cause`,
614
- * and camera health's own).
591
+ * resolved* and *was never attempted* — one field carrying two facts, each
592
+ * overwriting the other.
615
593
  *
616
594
  * So: `running` while work is happening, `finished` when the bridge will
617
595
  * send no more for this sync, `refused_busy` when it never started because
618
596
  * another sync was in flight. `failed` keeps its single meaning.
619
- *
620
- * Raised by Rosie-W7, who found the gap by asking what a second request
621
- * should do rather than picking the silent option.
622
597
  */
623
598
  state: z.enum(['running', 'finished', 'refused_busy']),
624
599
  });
@@ -639,15 +614,13 @@ export const bridgeCameraState = z.object({
639
614
  publishing: z.boolean(),
640
615
  error: z.object({ code: z.string().min(1), message: z.string().min(1) }).nullable(),
641
616
  /**
642
- * Why this frame was sent (W6a).
617
+ * Why this frame was sent.
643
618
  *
644
619
  * Without it, `{publishing: false, error: null}` is sent for **three
645
620
  * different things** — an answer to `camera_stop`, a stream stopped by a
646
- * configuration change, and a source that recovered — and the cloud can
647
- * only tell them apart by remembering what it saw before. Deriving a cause
648
- * from remembered state is precisely the inference this project keeps
649
- * finding to be wrong, and W6a exists because four failures had been
650
- * sharing one silence.
621
+ * configuration change, and a source that recovered — and the cloud can only
622
+ * tell them apart by remembering what it saw before. A cause derived from
623
+ * remembered state is a guess.
651
624
  *
652
625
  * `'command'` this frame answers a `camera_start` / `camera_stop`.
653
626
  * `'source'` unsolicited: the source's own health changed, whether or
@@ -657,29 +630,24 @@ export const bridgeCameraState = z.object({
657
630
  * failure, and it must not be logged as one.
658
631
  * `'live_lost'` publishing ended unexpectedly after it had started.
659
632
  *
660
- * Note it does **not** answer "which attempt is this?" — `camera_state`
661
- * still has no request id, and that remains a named deferral in cluster C.
662
- * `cause` says what kind of event this is; correlation is a separate fact
663
- * and giving one field both jobs would be the same mistake again.
633
+ * It does **not** answer "which attempt is this?" — `request_id` beside it
634
+ * does. `cause` says what kind of event this is; correlation is a separate
635
+ * fact, and giving one field both jobs would be the same mistake again.
664
636
  *
665
- * Required, not optional: an absent cause would default to the reading
666
- * somebody happens to assume, and every frame's sender knows its own
667
- * reason. Old bridges fail validation on this frame — acceptable while
668
- * nothing is deployed, and W8 is the first deployment.
637
+ * Required, not optional: an absent cause would default to whatever reading
638
+ * the receiver happens to assume, and every frame's sender knows its own
639
+ * reason.
669
640
  */
670
641
  cause: z.enum(['command', 'source', 'config_change', 'live_lost']),
671
642
  /**
672
643
  * When the **robot** observed this state — bridge capture time, never
673
- * receive time, the same discipline `timestamp_ms` follows for samples
674
- * (spec §6.3).
644
+ * receive time, the same discipline `timestamp_ms` follows for samples.
675
645
  *
676
- * It exists because the cloud stamped `resourceHealthState.changed_at_ms`
677
- * with its own `Date.now()`, and a **restatement** is by definition an old
678
- * state re-sent into an empty map. So after a cloud restart every failure —
679
- * including one from yesterday — was dated to the restart, in the one
680
- * scenario `changed_at_ms`'s own doc comment was written for: *"a page that
681
- * loads late must be able to tell a failure from a minute ago from one from
682
- * yesterday"*.
646
+ * It exists because a cloud that stamps `resourceHealthState.changed_at_ms`
647
+ * with its own clock dates every **restatement** to the moment it restarted
648
+ * — a restatement is by definition an old state re-sent into an empty map.
649
+ * That destroys exactly what `changed_at_ms` is for: a page that loads late
650
+ * must be able to tell a failure from a minute ago from one from yesterday.
683
651
  *
684
652
  * On a restatement this carries **when the state was first observed**, not
685
653
  * when the frame was sent. A bridge that re-states a failure it has held for
@@ -687,7 +655,7 @@ export const bridgeCameraState = z.object({
687
655
  */
688
656
  observed_at_ms: z.number().int().nonnegative(),
689
657
  /**
690
- * Which request this frame answers (W6b), or `null` when it answers none.
658
+ * Which request this frame answers, or `null` when it answers none.
691
659
  *
692
660
  * `null` is not a gap and must not be treated as one: a `cause: 'source'`
693
661
  * frame — the unsolicited health report that makes a wrong password visible
@@ -704,10 +672,10 @@ export const bridgeCameraState = z.object({
704
672
  * **The pairing rule is not in this schema, deliberately.** "Non-null iff
705
673
  * `cause === 'command'`" is a cross-field constraint; a zod `.refine()`
706
674
  * would express it at runtime and then **disappear** from the generated
707
- * JSON Schema, which is what the bridge vendors. The cloud would reject
708
- * frames the bridge had validated as correct — the same artifact/runtime
709
- * divergence that `.default()` publishing as `required` has produced four
710
- * times in this project, only pointing the other way. The rule is enforced
675
+ * JSON Schema, which is what a non-TypeScript bridge validates against. The
676
+ * cloud would reject frames the bridge had validated as correct — the same
677
+ * artifact-versus-runtime divergence that `.default()` publishing as
678
+ * `required` produces, pointing the other way. The rule is enforced
711
679
  * where the correlation is used, in the cloud's bridge frame handler, and
712
680
  * stated here so nobody has to derive it from that code.
713
681
  */