@fleetless/contracts 1.0.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.
Files changed (287) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +202 -0
  3. package/NOTICE +17 -0
  4. package/README.md +88 -0
  5. package/artifacts/constants.json +24 -0
  6. package/artifacts/openapi.json +17219 -0
  7. package/artifacts/routes.json +4605 -0
  8. package/artifacts/schema/accept-team-invite-request.schema.json +22 -0
  9. package/artifacts/schema/action-config.schema.json +198 -0
  10. package/artifacts/schema/alert-list-response.schema.json +172 -0
  11. package/artifacts/schema/api-error.schema.json +20 -0
  12. package/artifacts/schema/app-auth-config.schema.json +106 -0
  13. package/artifacts/schema/app-invitation-list-response.schema.json +57 -0
  14. package/artifacts/schema/app-invitation.schema.json +69 -0
  15. package/artifacts/schema/app-list-response.schema.json +82 -0
  16. package/artifacts/schema/app-mail-template-list-response.schema.json +68 -0
  17. package/artifacts/schema/app-mail-template.schema.json +54 -0
  18. package/artifacts/schema/app-oidc-provider-list-response.schema.json +93 -0
  19. package/artifacts/schema/app-oidc-provider.schema.json +80 -0
  20. package/artifacts/schema/app-user-list-response.schema.json +111 -0
  21. package/artifacts/schema/app-user.schema.json +98 -0
  22. package/artifacts/schema/app.schema.json +69 -0
  23. package/artifacts/schema/apply-error.schema.json +41 -0
  24. package/artifacts/schema/asset-list-response.schema.json +288 -0
  25. package/artifacts/schema/asset-sync-request.schema.json +17 -0
  26. package/artifacts/schema/asset-sync-response.schema.json +16 -0
  27. package/artifacts/schema/asset-sync-status.schema.json +136 -0
  28. package/artifacts/schema/asset.schema.json +68 -0
  29. package/artifacts/schema/audit-actor.schema.json +32 -0
  30. package/artifacts/schema/audit-event.schema.json +119 -0
  31. package/artifacts/schema/audit-list-response.schema.json +144 -0
  32. package/artifacts/schema/audit-query.schema.json +79 -0
  33. package/artifacts/schema/auth-error.schema.json +23 -0
  34. package/artifacts/schema/auth-me-response.schema.json +99 -0
  35. package/artifacts/schema/auth-ok.schema.json +115 -0
  36. package/artifacts/schema/authorization-server-metadata.schema.json +80 -0
  37. package/artifacts/schema/bridge-asset-progress.schema.json +99 -0
  38. package/artifacts/schema/bridge-assets-available.schema.json +25 -0
  39. package/artifacts/schema/bridge-camera-state.schema.json +78 -0
  40. package/artifacts/schema/bridge-config-applied.schema.json +67 -0
  41. package/artifacts/schema/bridge-hello.schema.json +65 -0
  42. package/artifacts/schema/bridge-introspect.schema.json +114 -0
  43. package/artifacts/schema/bridge-job-lost.schema.json +22 -0
  44. package/artifacts/schema/bridge-job-update.schema.json +100 -0
  45. package/artifacts/schema/bridge-pong.schema.json +19 -0
  46. package/artifacts/schema/bridge-pressure.schema.json +292 -0
  47. package/artifacts/schema/bridge-state.schema.json +24 -0
  48. package/artifacts/schema/bridge-type-definitions.schema.json +169 -0
  49. package/artifacts/schema/busy-details.schema.json +115 -0
  50. package/artifacts/schema/camera-descriptor.schema.json +45 -0
  51. package/artifacts/schema/camera-list-response.schema.json +58 -0
  52. package/artifacts/schema/camera-source.schema.json +240 -0
  53. package/artifacts/schema/cancel-request.schema.json +20 -0
  54. package/artifacts/schema/client-accept-invitation-request.schema.json +35 -0
  55. package/artifacts/schema/client-auth.schema.json +18 -0
  56. package/artifacts/schema/client-cancel.schema.json +45 -0
  57. package/artifacts/schema/client-identity.schema.json +103 -0
  58. package/artifacts/schema/client-invoke.schema.json +45 -0
  59. package/artifacts/schema/client-login-request.schema.json +29 -0
  60. package/artifacts/schema/client-logout-request.schema.json +14 -0
  61. package/artifacts/schema/client-mcp-interaction-decision-response.schema.json +15 -0
  62. package/artifacts/schema/client-mcp-interaction.schema.json +59 -0
  63. package/artifacts/schema/client-oidc-callback-query.schema.json +28 -0
  64. package/artifacts/schema/client-oidc-exchange-request.schema.json +21 -0
  65. package/artifacts/schema/client-oidc-start-query.schema.json +36 -0
  66. package/artifacts/schema/client-password-reset-confirm-request.schema.json +22 -0
  67. package/artifacts/schema/client-password-reset-request.schema.json +24 -0
  68. package/artifacts/schema/client-provider-list-query.schema.json +17 -0
  69. package/artifacts/schema/client-provider-list-response.schema.json +34 -0
  70. package/artifacts/schema/client-publish.schema.json +40 -0
  71. package/artifacts/schema/client-refresh-request.schema.json +14 -0
  72. package/artifacts/schema/client-register-request.schema.json +44 -0
  73. package/artifacts/schema/client-resend-verification-request.schema.json +24 -0
  74. package/artifacts/schema/client-subscribe.schema.json +43 -0
  75. package/artifacts/schema/client-unsubscribe.schema.json +26 -0
  76. package/artifacts/schema/client-verify-email-request.schema.json +15 -0
  77. package/artifacts/schema/cloud-asset-request.schema.json +37 -0
  78. package/artifacts/schema/cloud-camera-start.schema.json +41 -0
  79. package/artifacts/schema/cloud-camera-stop.schema.json +26 -0
  80. package/artifacts/schema/cloud-cancel.schema.json +33 -0
  81. package/artifacts/schema/cloud-config.schema.json +1635 -0
  82. package/artifacts/schema/cloud-hello-error.schema.json +23 -0
  83. package/artifacts/schema/cloud-hello-ok.schema.json +19 -0
  84. package/artifacts/schema/cloud-introspect-request.schema.json +19 -0
  85. package/artifacts/schema/cloud-invoke.schema.json +40 -0
  86. package/artifacts/schema/cloud-ping.schema.json +19 -0
  87. package/artifacts/schema/cloud-publish.schema.json +28 -0
  88. package/artifacts/schema/cloud-type-request.schema.json +30 -0
  89. package/artifacts/schema/command-result.schema.json +175 -0
  90. package/artifacts/schema/config-draft-response.schema.json +1695 -0
  91. package/artifacts/schema/config-state.schema.json +124 -0
  92. package/artifacts/schema/config-version-response.schema.json +1641 -0
  93. package/artifacts/schema/config-versions-response.schema.json +33 -0
  94. package/artifacts/schema/create-app-invitation-request.schema.json +40 -0
  95. package/artifacts/schema/create-app-oidc-provider-request.schema.json +70 -0
  96. package/artifacts/schema/create-app-request.schema.json +30 -0
  97. package/artifacts/schema/create-app-user-request.schema.json +42 -0
  98. package/artifacts/schema/create-robot-request.schema.json +14 -0
  99. package/artifacts/schema/create-robot-response.schema.json +44 -0
  100. package/artifacts/schema/create-server-key-response.schema.json +65 -0
  101. package/artifacts/schema/create-team-invite-request.schema.json +43 -0
  102. package/artifacts/schema/datapoint-alert-row.schema.json +160 -0
  103. package/artifacts/schema/datapoint-config.schema.json +366 -0
  104. package/artifacts/schema/datapoint-display.schema.json +31 -0
  105. package/artifacts/schema/datapoint-event.schema.json +34 -0
  106. package/artifacts/schema/datapoint-frame.schema.json +28 -0
  107. package/artifacts/schema/datapoint-list-response.schema.json +61 -0
  108. package/artifacts/schema/datapoint-value.schema.json +28 -0
  109. package/artifacts/schema/developer-login-request.schema.json +19 -0
  110. package/artifacts/schema/dynamic-client-registration-request.schema.json +60 -0
  111. package/artifacts/schema/dynamic-client-registration-response.schema.json +68 -0
  112. package/artifacts/schema/error-frame.schema.json +23 -0
  113. package/artifacts/schema/exposure-counts.schema.json +39 -0
  114. package/artifacts/schema/exposure-list-response.schema.json +43 -0
  115. package/artifacts/schema/fetch-types-request.schema.json +19 -0
  116. package/artifacts/schema/fetch-types-response.schema.json +163 -0
  117. package/artifacts/schema/fleetless-user-list-response.schema.json +73 -0
  118. package/artifacts/schema/fleetless-user.schema.json +60 -0
  119. package/artifacts/schema/history-buckets-response.schema.json +79 -0
  120. package/artifacts/schema/history-query.schema.json +58 -0
  121. package/artifacts/schema/history-response.schema.json +150 -0
  122. package/artifacts/schema/history-samples-response.schema.json +68 -0
  123. package/artifacts/schema/introspection-response.schema.json +118 -0
  124. package/artifacts/schema/invoke-or-service-response.schema.json +141 -0
  125. package/artifacts/schema/invoke-request.schema.json +23 -0
  126. package/artifacts/schema/invoke-response.schema.json +125 -0
  127. package/artifacts/schema/job-actor.schema.json +34 -0
  128. package/artifacts/schema/job-event.schema.json +158 -0
  129. package/artifacts/schema/job-response.schema.json +123 -0
  130. package/artifacts/schema/job-run-list-response.schema.json +222 -0
  131. package/artifacts/schema/job-run-query.schema.json +95 -0
  132. package/artifacts/schema/job-run-summary-query.schema.json +23 -0
  133. package/artifacts/schema/job-run-summary.schema.json +33 -0
  134. package/artifacts/schema/job-run.schema.json +195 -0
  135. package/artifacts/schema/job-state.schema.json +11 -0
  136. package/artifacts/schema/job.schema.json +106 -0
  137. package/artifacts/schema/latency-bucket.schema.json +63 -0
  138. package/artifacts/schema/live-session-response.schema.json +41 -0
  139. package/artifacts/schema/mail-outcome.schema.json +20 -0
  140. package/artifacts/schema/mail-template-preview-request.schema.json +35 -0
  141. package/artifacts/schema/mail-template-preview-response.schema.json +31 -0
  142. package/artifacts/schema/mail-template-problem-details.schema.json +24 -0
  143. package/artifacts/schema/mcp-consent-grant-list-response.schema.json +52 -0
  144. package/artifacts/schema/mcp-consent-grant.schema.json +39 -0
  145. package/artifacts/schema/mcp-robot-datasheet.schema.json +115 -0
  146. package/artifacts/schema/mcp-role-preview-response.schema.json +134 -0
  147. package/artifacts/schema/missing-asset-query.schema.json +11 -0
  148. package/artifacts/schema/oauth-authorize-query.schema.json +47 -0
  149. package/artifacts/schema/oauth-redirect-response.schema.json +15 -0
  150. package/artifacts/schema/oauth-token-request.schema.json +47 -0
  151. package/artifacts/schema/oauth-token-response.schema.json +38 -0
  152. package/artifacts/schema/org-alerts-query.schema.json +15 -0
  153. package/artifacts/schema/org-event-dropped.schema.json +26 -0
  154. package/artifacts/schema/org-event-replay.schema.json +97 -0
  155. package/artifacts/schema/org-event-subscribe.schema.json +14 -0
  156. package/artifacts/schema/org-event-unsubscribe.schema.json +14 -0
  157. package/artifacts/schema/org-event.schema.json +75 -0
  158. package/artifacts/schema/org-firing-alerts-response.schema.json +178 -0
  159. package/artifacts/schema/org-health-query.schema.json +13 -0
  160. package/artifacts/schema/org-latency-query.schema.json +42 -0
  161. package/artifacts/schema/org-latency-response.schema.json +124 -0
  162. package/artifacts/schema/org-quota-usage-counts.schema.json +42 -0
  163. package/artifacts/schema/org-quota-usage.schema.json +102 -0
  164. package/artifacts/schema/org-quotas.schema.json +51 -0
  165. package/artifacts/schema/org-usage-query.schema.json +19 -0
  166. package/artifacts/schema/org-usage-response.schema.json +77 -0
  167. package/artifacts/schema/org.schema.json +30 -0
  168. package/artifacts/schema/parameter-invalid-details.schema.json +37 -0
  169. package/artifacts/schema/parameter-spec.schema.json +120 -0
  170. package/artifacts/schema/parameter-violation.schema.json +24 -0
  171. package/artifacts/schema/password-change-request.schema.json +21 -0
  172. package/artifacts/schema/password-reset-confirm.schema.json +19 -0
  173. package/artifacts/schema/password-reset-request.schema.json +14 -0
  174. package/artifacts/schema/patch-app-oidc-provider-request.schema.json +50 -0
  175. package/artifacts/schema/patch-app-user-request.schema.json +34 -0
  176. package/artifacts/schema/patch-auth-me-request.schema.json +22 -0
  177. package/artifacts/schema/patch-fleetless-user-request.schema.json +20 -0
  178. package/artifacts/schema/patch-org-request.schema.json +15 -0
  179. package/artifacts/schema/patch-org-response.schema.json +40 -0
  180. package/artifacts/schema/patch-robot-request.schema.json +15 -0
  181. package/artifacts/schema/patch-robot-response.schema.json +40 -0
  182. package/artifacts/schema/pending-team-invite-list-response.schema.json +52 -0
  183. package/artifacts/schema/pending-team-invite.schema.json +39 -0
  184. package/artifacts/schema/protected-resource-metadata.schema.json +41 -0
  185. package/artifacts/schema/publish-config-response.schema.json +21 -0
  186. package/artifacts/schema/publish-request.schema.json +17 -0
  187. package/artifacts/schema/publisher-config.schema.json +285 -0
  188. package/artifacts/schema/put-app-auth-config-request.schema.json +93 -0
  189. package/artifacts/schema/put-app-mail-template-request.schema.json +35 -0
  190. package/artifacts/schema/put-config-draft-request.schema.json +13 -0
  191. package/artifacts/schema/put-datapoint-display-request.schema.json +31 -0
  192. package/artifacts/schema/put-robot-details-request.schema.json +41 -0
  193. package/artifacts/schema/put-robot-details-response.schema.json +43 -0
  194. package/artifacts/schema/rate-limit-details.schema.json +15 -0
  195. package/artifacts/schema/refresh-request.schema.json +13 -0
  196. package/artifacts/schema/release-live-query.schema.json +13 -0
  197. package/artifacts/schema/rename-slug-request.schema.json +23 -0
  198. package/artifacts/schema/rename-slug-response.schema.json +24 -0
  199. package/artifacts/schema/resource-health-event.schema.json +72 -0
  200. package/artifacts/schema/resource-health-list-response.schema.json +80 -0
  201. package/artifacts/schema/resource-health-state.schema.json +68 -0
  202. package/artifacts/schema/robot-config-doc.schema.json +1616 -0
  203. package/artifacts/schema/robot-delete-query.schema.json +12 -0
  204. package/artifacts/schema/robot-deletion-summary.schema.json +63 -0
  205. package/artifacts/schema/robot-detail-response.schema.json +262 -0
  206. package/artifacts/schema/robot-details-doc.schema.json +33 -0
  207. package/artifacts/schema/robot-jobs-response.schema.json +119 -0
  208. package/artifacts/schema/robot-latency-series.schema.json +81 -0
  209. package/artifacts/schema/robot-list-item.schema.json +94 -0
  210. package/artifacts/schema/robot-list-response.schema.json +106 -0
  211. package/artifacts/schema/robot.schema.json +30 -0
  212. package/artifacts/schema/role-list-response.schema.json +48 -0
  213. package/artifacts/schema/role-permissions.schema.json +61 -0
  214. package/artifacts/schema/role.schema.json +35 -0
  215. package/artifacts/schema/ros-graph.schema.json +99 -0
  216. package/artifacts/schema/server-key-list-response.schema.json +64 -0
  217. package/artifacts/schema/server-key.schema.json +51 -0
  218. package/artifacts/schema/service-call-response.schema.json +13 -0
  219. package/artifacts/schema/service-config.schema.json +198 -0
  220. package/artifacts/schema/session-tokens.schema.json +28 -0
  221. package/artifacts/schema/sign-up-request.schema.json +26 -0
  222. package/artifacts/schema/sign-up-response.schema.json +127 -0
  223. package/artifacts/schema/slug-usage-response.schema.json +32 -0
  224. package/artifacts/schema/snapshot-header.schema.json +44 -0
  225. package/artifacts/schema/snapshot-meta-response.schema.json +85 -0
  226. package/artifacts/schema/subscribe-error.schema.json +31 -0
  227. package/artifacts/schema/team-invite.schema.json +57 -0
  228. package/artifacts/schema/tier-change-request.schema.json +17 -0
  229. package/artifacts/schema/type-definition.schema.json +144 -0
  230. package/artifacts/schema/types-response.schema.json +156 -0
  231. package/artifacts/schema/update-app-request.schema.json +32 -0
  232. package/artifacts/schema/urdf-completeness.schema.json +50 -0
  233. package/artifacts/schema/validation-issue.schema.json +43 -0
  234. package/artifacts/schema/waitlist-request.schema.json +15 -0
  235. package/artifacts/schema-outgoing/bridge-asset-progress.schema.json +102 -0
  236. package/artifacts/schema-outgoing/bridge-assets-available.schema.json +26 -0
  237. package/artifacts/schema-outgoing/bridge-camera-state.schema.json +80 -0
  238. package/artifacts/schema-outgoing/bridge-config-applied.schema.json +69 -0
  239. package/artifacts/schema-outgoing/bridge-hello.schema.json +68 -0
  240. package/artifacts/schema-outgoing/bridge-introspect.schema.json +119 -0
  241. package/artifacts/schema-outgoing/bridge-job-lost.schema.json +23 -0
  242. package/artifacts/schema-outgoing/bridge-job-update.schema.json +102 -0
  243. package/artifacts/schema-outgoing/bridge-pong.schema.json +20 -0
  244. package/artifacts/schema-outgoing/bridge-type-definitions.schema.json +174 -0
  245. package/artifacts/schema-outgoing/datapoint-frame.schema.json +29 -0
  246. package/artifacts/schema-outgoing/snapshot-header.schema.json +45 -0
  247. package/dist/alerts.d.ts +255 -0
  248. package/dist/alerts.js +193 -0
  249. package/dist/app-users.d.ts +606 -0
  250. package/dist/app-users.js +696 -0
  251. package/dist/apps.d.ts +175 -0
  252. package/dist/apps.js +267 -0
  253. package/dist/assets.d.ts +434 -0
  254. package/dist/assets.js +546 -0
  255. package/dist/audit.d.ts +129 -0
  256. package/dist/audit.js +238 -0
  257. package/dist/client-auth.d.ts +409 -0
  258. package/dist/client-auth.js +487 -0
  259. package/dist/common.d.ts +186 -0
  260. package/dist/common.js +199 -0
  261. package/dist/config-issues.d.ts +175 -0
  262. package/dist/config-issues.js +339 -0
  263. package/dist/config.d.ts +862 -0
  264. package/dist/config.js +1988 -0
  265. package/dist/errors.d.ts +52 -0
  266. package/dist/errors.js +786 -0
  267. package/dist/identity.d.ts +549 -0
  268. package/dist/identity.js +503 -0
  269. package/dist/index.d.ts +51 -0
  270. package/dist/index.js +51 -0
  271. package/dist/introspection.d.ts +99 -0
  272. package/dist/introspection.js +97 -0
  273. package/dist/jobs.d.ts +334 -0
  274. package/dist/jobs.js +345 -0
  275. package/dist/mcp.d.ts +239 -0
  276. package/dist/mcp.js +153 -0
  277. package/dist/oauth.d.ts +344 -0
  278. package/dist/oauth.js +488 -0
  279. package/dist/protocol.d.ts +781 -0
  280. package/dist/protocol.js +715 -0
  281. package/dist/realtime.d.ts +494 -0
  282. package/dist/realtime.js +512 -0
  283. package/dist/rest.d.ts +1989 -0
  284. package/dist/rest.js +1963 -0
  285. package/dist/routes.d.ts +94 -0
  286. package/dist/routes.js +2298 -0
  287. package/package.json +61 -0
@@ -0,0 +1,715 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { z } from 'zod';
3
+ import { assetFailure } from './assets.js';
4
+ import { applyError, slug } from './common.js';
5
+ import { robotConfigDoc } from './config.js';
6
+ import { rosGraph, typeDefinition } from './introspection.js';
7
+ import { jobState } from './jobs.js';
8
+ import { rosTypeName } from './common.js';
9
+ /**
10
+ * Bridge <-> cloud protocol, version 2.
11
+ *
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`.
16
+ *
17
+ * **2 (2026-08-21):** `config_applied.errors` entries gained `kind` and `code`
18
+ * beside `message`. The check is `!==`, not a floor, so a bridge that is not
19
+ * exactly this version is refused entirely. That is deliberate: a cloud and a
20
+ * bridge that disagree about the wire should not pretend otherwise.
21
+ */
22
+ export const PROTOCOL_VERSION = 2;
23
+ /**
24
+ * The bridge socket close code for "this robot no longer exists" (W6a).
25
+ *
26
+ * Deliberately distinct from the auth failures: a deleted robot must **stop**,
27
+ * and a token that was valid a second ago is indistinguishable from one that
28
+ * was revoked unless the cloud says which. Without its own code the bridge
29
+ * reconnects forever against a robot that will never come back — a permanent
30
+ * load on the cloud, and a robot on someone's shelf whose logs say nothing
31
+ * more informative than "connection closed".
32
+ */
33
+ export const CLOSE_ROBOT_DELETED = 4004;
34
+ /**
35
+ * How long a command waits for its answer when the caller names no patience
36
+ * of its own (W6b).
37
+ *
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.
43
+ *
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.
47
+ */
48
+ export const DEFAULT_PATIENCE_MS = 15_000;
49
+ /**
50
+ * The longest patience a caller may ask for.
51
+ *
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
56
+ * manoeuvre, an arm trajectory) and short enough that a thousand of them is
57
+ * still a bounded amount of cloud.
58
+ *
59
+ * Raising it is a W8 conversation, after rate limiting exists — not a
60
+ * one-line change here.
61
+ */
62
+ export const MAX_PATIENCE_MS = 120_000;
63
+ /**
64
+ * The shortest patience a caller may ask for.
65
+ *
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.
72
+ *
73
+ * One second, because it has to be longer than a goal-acceptance round trip on
74
+ * a healthy robot and shorter than any wait a human would call patient. It is
75
+ * a guard against a number that cannot be satisfied, not a policy about how
76
+ * long work takes — `MAX_PATIENCE_MS` is the end that bounds the platform,
77
+ * this end bounds what the caller can do to the robot.
78
+ */
79
+ export const MIN_PATIENCE_MS = 1_000;
80
+ /** Re-exported so consumers keep importing wire names from one place. */
81
+ export { slug } from './common.js';
82
+ /**
83
+ * One job the bridge still has, as reported in the handshake (W6b).
84
+ *
85
+ * It carries the **slug and the state**, not only the id, because the cloud's
86
+ * reconciliation needs both and had neither. Reading `active_job_ids` as bare
87
+ * uuids, a restarted cloud could answer exactly one question — "is this job
88
+ * still alive?" — for jobs it already knew about. It could not name what the
89
+ * robot is doing, could not tell a job that is still `running` from one that
90
+ * finished while the cloud was down, and had nothing at all to say about a
91
+ * job it never recorded because it crashed between minting the id and writing
92
+ * the row.
93
+ *
94
+ * `state` is the bridge's own current answer, not a history. A bridge that
95
+ * has a terminal result still in hand reports it here and the cloud writes it
96
+ * down, instead of publishing `lost` over a job that in fact succeeded.
97
+ */
98
+ export const activeJob = z.object({
99
+ job_id: z.uuid(),
100
+ slug,
101
+ state: jobState,
102
+ });
103
+ /** First frame a bridge sends after the socket opens. */
104
+ export const bridgeHello = z.object({
105
+ type: z.literal('hello'),
106
+ protocol_version: z.number().int().positive(),
107
+ token: z.string().min(1),
108
+ bridge_version: z.string().min(1),
109
+ /**
110
+ * Every job this bridge still knows about, right now (spec §6.1, W4).
111
+ *
112
+ * A reconnect and a restart look **identical** on the wire otherwise: same
113
+ * token, same version, same frame. But they must end differently — after a
114
+ * dropped connection the running jobs are still running, after a restart
115
+ * their results are gone forever. Asking the bridge to enumerate what it
116
+ * still has settles it without either side guessing: the cloud marks every
117
+ * job it believes is running on this robot and that is *not* named here as
118
+ * `lost`.
119
+ *
120
+ * This deliberately needs no persistence at the bridge. A live process
121
+ * lists its live jobs; a process that just started lists none, because it
122
+ * has none — which is exactly the truth the cloud needs. A breadcrumb file
123
+ * would only add a window in which the crash beat the write.
124
+ *
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.
135
+ */
136
+ active_jobs: z.array(activeJob).max(500).default([]),
137
+ });
138
+ /** Cloud accepts the bridge: the robot is online from here on. */
139
+ export const cloudHelloOk = z.object({
140
+ type: z.literal('hello_ok'),
141
+ robot_id: z.uuid(),
142
+ });
143
+ /** Cloud refuses the bridge (bad token, incompatible protocol, ...). */
144
+ export const cloudHelloError = z.object({
145
+ type: z.literal('hello_error'),
146
+ code: z.string().min(1),
147
+ message: z.string().min(1),
148
+ });
149
+ /**
150
+ * 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).
152
+ */
153
+ export const datapointFrame = z.object({
154
+ type: z.literal('datapoint'),
155
+ slug,
156
+ value: z.unknown(),
157
+ timestamp_ms: z.number().int().nonnegative(),
158
+ });
159
+ /**
160
+ * Latency probe, cloud → bridge. The cloud sends its own clock in `ts_ms`;
161
+ * the bridge echoes it back untouched and the cloud derives the round-trip
162
+ * latency shown as `bridge_state.latency_ms`.
163
+ */
164
+ export const cloudPing = z.object({
165
+ type: z.literal('ping'),
166
+ ts_ms: z.number().int().nonnegative(),
167
+ });
168
+ /** Immediate bridge answer to a `CloudPing`, `ts_ms` echoed unchanged. */
169
+ export const bridgePong = z.object({
170
+ type: z.literal('pong'),
171
+ ts_ms: z.number().int().nonnegative(),
172
+ });
173
+ /**
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.
177
+ *
178
+ * `version: 0` with an empty document means *nothing published yet* — a fresh
179
+ * robot, not an error.
180
+ */
181
+ /**
182
+ * Cloud → bridge: the configuration to apply.
183
+ *
184
+ * **`doc` is the whole of it.** Camera credentials travel inline in
185
+ * `doc.cameras[].source`, per `config.ts`'s `cameraCredentials` — there is no
186
+ * side channel any more. That was a deliberate, recorded trade-off: a
187
+ * password here is in every published version, and those are immutable. The
188
+ * bound on that decision is elsewhere and load-bearing — the publish audit
189
+ * event and the org event stream must not carry the document body.
190
+ *
191
+ * **The bridge does not persist configuration.** It holds this frame in
192
+ * memory and is sent it again on every reconnect, and that is the only thing
193
+ * keeping camera passwords off the robot's disk. The retired side channel
194
+ * carried this warning with an escape hatch attached — cache the config, just
195
+ * exclude the `credentials` field. There is no such field now: the secrets are
196
+ * inside `doc`, so caching the configuration caches the passwords, with
197
+ * nothing left to leave out. The warning survives its own mechanism, narrower
198
+ * and harder to satisfy than when it was written.
199
+ */
200
+ export const cloudConfig = z.object({
201
+ type: z.literal('config'),
202
+ version: z.number().int().nonnegative(),
203
+ doc: robotConfigDoc,
204
+ });
205
+ /**
206
+ * What the bridge made of it. A single unusable entry must never stop the
207
+ * others: the bridge applies what it can, reports the rest per slug, and
208
+ * sets `ok: false`. The console shows this as "published v2 · applied v1".
209
+ */
210
+ export const bridgeConfigApplied = z.object({
211
+ type: z.literal('config_applied'),
212
+ version: z.number().int().nonnegative(),
213
+ ok: z.boolean(),
214
+ errors: z.array(applyError),
215
+ });
216
+ /**
217
+ * Commands, cloud → bridge (spec §6.1, §11.3). The **cloud** mints the
218
+ * `job_id` before the bridge is asked to do anything, so a job exists —
219
+ * and can be reported `lost` — even if the answer never comes back.
220
+ */
221
+ export const cloudInvoke = z.object({
222
+ type: z.literal('invoke'),
223
+ job_id: z.uuid(),
224
+ slug,
225
+ /**
226
+ * Already validated against §4.4 rules; the bridge validates structurally.
227
+ *
228
+ * **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.
233
+ *
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.
241
+ *
242
+ * The bridge substitutes these values into the entry's `message` template
243
+ * at its placeholder positions. It no longer unflattens a dotted path;
244
+ * there is no dotted path to unflatten.
245
+ */
246
+ params: z.record(z.string(), z.unknown()),
247
+ /**
248
+ * How long this one call is worth waiting for (W6b), already resolved by
249
+ * the cloud — the caller's `invokeRequest.patience_ms`, or
250
+ * `DEFAULT_PATIENCE_MS` when they named none.
251
+ *
252
+ * **Required here, optional at REST**, deliberately. At the REST edge an
253
+ * absent value is a caller who did not care and gets the default. By the
254
+ * time the frame is on this socket somebody has decided, and the bridge
255
+ * 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.
260
+ */
261
+ patience_ms: z.number().int().min(MIN_PATIENCE_MS).max(MAX_PATIENCE_MS),
262
+ });
263
+ /**
264
+ * Cancel — the bridge must issue a real ROS goal cancel (§11.3).
265
+ *
266
+ * `slug` stays, and stays required: it is how the bridge finds the tracker,
267
+ * and it is what a cancel with no id means.
268
+ *
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
271
+ * same slug stops the **new** one — the caller asked to stop something that
272
+ * had already finished and stopped a machine that had just started moving.
273
+ * That is not a race anybody had to lose: the caller knew the id, and the
274
+ * wire had nowhere to put it.
275
+ *
276
+ * `null` keeps today's meaning and must be read as exactly that: *cancel
277
+ * whatever is running on this slug*. It is a real request — an operator
278
+ * hitting stop wants the robot stopped, not a lecture about job identity —
279
+ * and it stays available for that. A bridge given an id that does not match
280
+ * what is running cancels **nothing** and says so; it must not fall back to
281
+ * the slug, because a caller who named an id has ruled that out.
282
+ */
283
+ export const cloudCancel = z.object({
284
+ type: z.literal('cancel'),
285
+ slug,
286
+ job_id: z.uuid().nullable(),
287
+ });
288
+ export const cloudPublish = z.object({
289
+ type: z.literal('publish'),
290
+ slug,
291
+ /**
292
+ * Flat and keyed by parameter name, exactly like `cloudInvoke.params` — a
293
+ * publisher declares parameters and takes the same validation, so it takes
294
+ * the same shape.
295
+ *
296
+ * This is *not* the shape of `publisherConfig.failsafe.message`, which is a
297
+ * complete ROS message template. The failsafe is authored once against the
298
+ * type, sent by the bridge with no caller present, and refused outright if
299
+ * it contains a placeholder — there would be nobody to fill it.
300
+ */
301
+ message: z.record(z.string(), z.unknown()),
302
+ });
303
+ /**
304
+ * 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).
306
+ */
307
+ export const bridgeJobUpdate = z.object({
308
+ type: z.literal('job_update'),
309
+ job_id: z.uuid(),
310
+ slug,
311
+ state: jobState,
312
+ feedback: z.unknown().nullable(),
313
+ progress: z.number().min(0).max(1).nullable(),
314
+ result: z.unknown().nullable(),
315
+ /** Same shape as `job.error`, `details` included — see `jobs.ts`. */
316
+ error: z
317
+ .object({ code: z.string().min(1), message: z.string().min(1), details: z.unknown().optional() })
318
+ .nullable(),
319
+ timestamp_ms: z.number().int().nonnegative(),
320
+ });
321
+ /**
322
+ * Jobs the bridge can no longer account for **while connected** (§6.1) — a
323
+ * tracker dropped, an action server that vanished mid-goal, anything where
324
+ * the honest answer is "I lost this" rather than a state.
325
+ *
326
+ * The restart case is not this frame's job: a restarted bridge has nothing
327
+ * left to enumerate, so it is `hello.active_job_ids` that closes that gap.
328
+ * Both paths end in the same place — the cloud publishes `lost` rather than
329
+ * leaving a job reading "running" because nobody contradicted it.
330
+ */
331
+ export const bridgeJobLost = z.object({
332
+ type: z.literal('job_lost'),
333
+ job_ids: z.array(z.uuid()),
334
+ });
335
+ /** Cloud asks for a fresh ROS graph; `request_id` correlates the answer. */
336
+ export const cloudIntrospectRequest = z.object({
337
+ type: z.literal('introspect_request'),
338
+ request_id: z.string().min(1).max(64),
339
+ });
340
+ /** The graph snapshot, bridge → cloud. */
341
+ export const bridgeIntrospect = z.object({
342
+ type: z.literal('introspect'),
343
+ request_id: z.string().min(1).max(64),
344
+ graph: rosGraph,
345
+ });
346
+ /**
347
+ * Field trees are fetched on demand, not shipped with the graph: a robot with
348
+ * hundreds of topics would otherwise push hundreds of kilobytes on every
349
+ * refresh, for types nobody opened.
350
+ */
351
+ export const cloudTypeRequest = z.object({
352
+ type: z.literal('type_request'),
353
+ request_id: z.string().min(1).max(64),
354
+ type_names: z.array(rosTypeName).min(1).max(50),
355
+ });
356
+ /**
357
+ * The resolved definitions. Names the bridge cannot resolve in its sourced
358
+ * workspace are listed in `unresolved` — an unknown type is an answer, not a
359
+ * failed frame.
360
+ */
361
+ export const bridgeTypeDefinitions = z.object({
362
+ type: z.literal('type_definitions'),
363
+ request_id: z.string().min(1).max(64),
364
+ definitions: z.array(typeDefinition),
365
+ unresolved: z.array(z.string()),
366
+ });
367
+ /**
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.
370
+ */
371
+ export const bridgeState = z.object({
372
+ online: z.boolean(),
373
+ latency_ms: z.number().nonnegative().nullable(),
374
+ });
375
+ /** One tier's counters, `tiers` below carries six of these under string keys. */
376
+ const bridgePressureTier = z.object({
377
+ sent: z.number().int().nonnegative(),
378
+ bytes: z.number().int().nonnegative(),
379
+ drops: z.number().int().nonnegative(),
380
+ high_water: z.number().int().nonnegative(),
381
+ });
382
+ /**
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
385
+ * bandwidth-shaping state, sent on the same reserved-slug path as
386
+ * `bridge_state` so history, realtime, REST and MCP exposure fall out of the
387
+ * ordinary datapoint machinery for free.
388
+ */
389
+ export const bridgePressure = z.object({
390
+ link: z.object({
391
+ /** bytes/s the socket demonstrably drains, from sends >= 64 KiB
392
+ * only; null until the first large send of the session. */
393
+ rate_bps: z.number().nonnegative().nullable(),
394
+ /**
395
+ * the byte target snapshots are currently encoded to fit.
396
+ *
397
+ * `.nonnegative()`, not `.positive()`: the target is derived from
398
+ * `rate_bps`, and a link measured below 0.5 B/s floors to 0 here. A
399
+ * schema that rejects 0 does not prevent that link — it only makes the
400
+ * frame reporting it unparseable, and a console that cannot parse a
401
+ * pressure frame shows "no feed", i.e. reports a struggling robot as an
402
+ * *old* one. Zero is a legitimate reading and says something true.
403
+ */
404
+ snapshot_max_bytes: z.number().int().nonnegative(),
405
+ }),
406
+ /**
407
+ * String keys "0".."5" because JSON has no integer keys. Counters are
408
+ * cumulative per session and reset on reconnect; clients window by
409
+ * differencing two samples.
410
+ *
411
+ * **What this schema does not decide:** it does not guarantee all six
412
+ * keys are present (`z.record` over the six literals is exhaustive in
413
+ * zod 4 — tested here, it required every key and rejected none, the
414
+ * opposite of what a partial sample needs — so this is a
415
+ * `.strictObject().partial()` over the same six literal keys instead, a
416
+ * deliberate deviation from the originally sketched `z.record` shape with
417
+ * the same runtime behaviour). A missing tier key reads as zeros; the
418
+ * schema names what it cannot decide rather than implying a completeness
419
+ * it cannot check.
420
+ */
421
+ tiers: z
422
+ .strictObject({
423
+ '0': bridgePressureTier,
424
+ '1': bridgePressureTier,
425
+ '2': bridgePressureTier,
426
+ '3': bridgePressureTier,
427
+ '4': bridgePressureTier,
428
+ '5': bridgePressureTier,
429
+ })
430
+ .partial(),
431
+ video: z.object({
432
+ active_streams: z.number().int().nonnegative(),
433
+ bitrate_sum_kbps: z.number().int().nonnegative(),
434
+ /**
435
+ * The uplink budget the bridge was configured with
436
+ * (`FLEETLESS_UPLINK_KBPS`), or `null` when none was set.
437
+ *
438
+ * `.nonnegative()`, not `.positive()`: `FLEETLESS_UPLINK_KBPS=0` is a
439
+ * documented setting meaning "no video budget at all", and the bridge
440
+ * emits that 0 verbatim. `.positive()` made every frame from such a
441
+ * robot fail the console's `safeParse`, which renders an unparseable
442
+ * frame as "no pressure feed" — so the one robot that had *deliberately*
443
+ * turned video off was the one diagnosed as running a bridge too old to
444
+ * report pressure. A value the producer legitimately sends must parse;
445
+ * `null` is the only "not set" this field has.
446
+ */
447
+ uplink_kbps: z.number().int().nonnegative().nullable(),
448
+ override_kbps: z.number().int().nonnegative().nullable(),
449
+ video_budget_kbps: z.number().int().nonnegative().nullable(),
450
+ reserve_kbps: z.number().int().nonnegative(),
451
+ }),
452
+ });
453
+ export const PRESSURE_SLUG = 'bridge_pressure';
454
+ /* ------------------------------------------------------------------ W5 --
455
+ * Cameras (spec §10).
456
+ */
457
+ /**
458
+ * The header of a **binary** snapshot frame, bridge → cloud.
459
+ *
460
+ * A snapshot frame is laid out as:
461
+ *
462
+ * [4-byte big-endian header length][UTF-8 JSON header][image bytes]
463
+ *
464
+ * Binary rather than base64 in a text frame, because base64 costs a third of
465
+ * the robot's upstream for nothing. Self-contained rather than a JSON frame
466
+ * 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.
469
+ *
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.
474
+ */
475
+ /**
476
+ * The largest a snapshot frame — header and image bytes together — may be on
477
+ * the `/bridge` socket.
478
+ *
479
+ * This is a **byte** bound and not a pixel one, deliberately. A camera's
480
+ * `width`/`height` govern the *live* stream, which travels through LiveKit
481
+ * and never touches this socket, so capping resolution to protect the socket
482
+ * would cost live quality to solve a snapshot problem.
483
+ *
484
+ * The bound exists because exceeding it is not a dropped frame: `ws` enforces
485
+ * its payload limit before the frame is ever delivered and closes the
486
+ * connection with 1009 — taking datapoints, jobs, commands and configuration
487
+ * down with it. The bridge would then reconnect, receive the same
488
+ * 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.
493
+ *
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.
498
+ */
499
+ export const SNAPSHOT_MAX_BYTES = 1_572_864; // 1.5 MiB, against a 2 MiB socket ceiling
500
+ export const snapshotHeader = z.object({
501
+ type: z.literal('snapshot'),
502
+ slug,
503
+ /** `image/jpeg` in practice; stated so nothing has to sniff the bytes. */
504
+ mime: z.string().min(1).max(64),
505
+ width: z.number().int().positive(),
506
+ height: z.number().int().positive(),
507
+ timestamp_ms: z.number().int().nonnegative(),
508
+ });
509
+ /**
510
+ * Cloud → bridge: start publishing this camera live.
511
+ *
512
+ * 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
514
+ * refcount must own the identity of the stream, or a robot could end up
515
+ * publishing into a room nobody is watching.
516
+ */
517
+ export const cloudCameraStart = z.object({
518
+ type: z.literal('camera_start'),
519
+ slug,
520
+ url: z.string().min(1),
521
+ room: z.string().min(1),
522
+ token: z.string().min(1),
523
+ /**
524
+ * Names **this attempt** (W6b), and is echoed in the `camera_state` that
525
+ * answers it.
526
+ *
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
530
+ * the second is in flight, matches on slug, and resolves the attempt it
531
+ * knows nothing about. The viewer is then told the running stream failed,
532
+ * for a reason belonging to an attempt that is already over.
533
+ */
534
+ request_id: z.string().min(1).max(64),
535
+ });
536
+ /** Cloud → bridge: the last viewer left; stop publishing (§10 refcount). */
537
+ /**
538
+ * Assets (spec §4.6, W7): the bridge **reports availability and transfers
539
+ * nothing** until asked.
540
+ *
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
544
+ * unauthenticated path. So the socket carries the *conversation* — what exists,
545
+ * transfer this, here is how far I got — and the bytes go over HTTP with the
546
+ * robot's own credential.
547
+ *
548
+ * That split is the whole design: a 40 MB mesh cannot stall the frames that
549
+ * keep a robot answerable, and a failed upload cannot take the control channel
550
+ * down with it.
551
+ */
552
+ export const bridgeAssetsAvailable = z.object({
553
+ type: z.literal('assets_available'),
554
+ /** Whether `/robot_description` (or the configured source) yielded a URDF. */
555
+ urdf: z.boolean(),
556
+ /**
557
+ * Every `package://` URI the URDF references, verbatim and unresolved —
558
+ * including the ones this bridge cannot find in its workspace. Reporting
559
+ * only the resolvable ones would make an incomplete workspace look like a
560
+ * complete robot, and the cloud would have nothing to show as missing.
561
+ */
562
+ meshes: z.array(z.string().min(1)),
563
+ });
564
+ /**
565
+ * The explicit request §4.6 requires — nothing moves without it.
566
+ *
567
+ * The upload credential is minted per sync and travels here rather than being
568
+ * derived from the robot token: it is scoped to one robot's assets and one
569
+ * sync, so a bridge cannot be talked into uploading somewhere else, and an
570
+ * expired one fails a sync instead of failing a robot.
571
+ */
572
+ export const cloudAssetRequest = z.object({
573
+ type: z.literal('asset_request'),
574
+ sync_id: z.uuid(),
575
+ upload_url: z.url(),
576
+ token: z.string().min(1),
577
+ /** Which URIs to send. Empty means the URDF only. */
578
+ meshes: z.array(z.string().min(1)),
579
+ });
580
+ /**
581
+ * How far a sync got, and — required, not optional — what it could not do.
582
+ *
583
+ * `failed` carries the URIs that did not resolve. A sync that quietly drops
584
+ * three meshes and reports success moves the failure into somebody else's
585
+ * renderer, where it appears as a robot with missing limbs and no cause.
586
+ */
587
+ export const bridgeAssetProgress = z.object({
588
+ type: z.literal('asset_progress'),
589
+ sync_id: z.uuid(),
590
+ done: z.number().int().nonnegative(),
591
+ total: z.number().int().nonnegative(),
592
+ /**
593
+ * **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.
600
+ */
601
+ failed: z.array(assetFailure).max(1000),
602
+ /**
603
+ * Three values, because a boolean `finished` had nowhere to put a refusal.
604
+ *
605
+ * A second `asset_request` arriving while one is in flight has to be
606
+ * answered with something. The bridge's guard is a backstop — the cloud owns
607
+ * sync lifecycle and refuses a concurrent one first — but a backstop that
608
+ * answers with silence is a backstop nobody can debug, and the alternative
609
+ * on the table was to report every requested URI in `failed`. That would
610
+ * 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).
615
+ *
616
+ * So: `running` while work is happening, `finished` when the bridge will
617
+ * send no more for this sync, `refused_busy` when it never started because
618
+ * 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
+ */
623
+ state: z.enum(['running', 'finished', 'refused_busy']),
624
+ });
625
+ export const cloudCameraStop = z.object({
626
+ type: z.literal('camera_stop'),
627
+ slug,
628
+ /** Names this stop, echoed by the `camera_state` that answers it — see `cloudCameraStart.request_id`. */
629
+ request_id: z.string().min(1).max(64),
630
+ });
631
+ /**
632
+ * What the bridge made of it. `publishing: false` with an `error` is how a
633
+ * camera that cannot start says so — the cloud must not leave a viewer
634
+ * watching a black rectangle while believing the stream is live.
635
+ */
636
+ export const bridgeCameraState = z.object({
637
+ type: z.literal('camera_state'),
638
+ slug,
639
+ publishing: z.boolean(),
640
+ error: z.object({ code: z.string().min(1), message: z.string().min(1) }).nullable(),
641
+ /**
642
+ * Why this frame was sent (W6a).
643
+ *
644
+ * Without it, `{publishing: false, error: null}` is sent for **three
645
+ * 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.
651
+ *
652
+ * `'command'` this frame answers a `camera_start` / `camera_stop`.
653
+ * `'source'` unsolicited: the source's own health changed, whether or
654
+ * not anybody is watching. This is the frame that makes a
655
+ * wrong password visible without a viewer.
656
+ * `'config_change'` a configuration change stopped this stream. Not a
657
+ * failure, and it must not be logged as one.
658
+ * `'live_lost'` publishing ended unexpectedly after it had started.
659
+ *
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.
664
+ *
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.
669
+ */
670
+ cause: z.enum(['command', 'source', 'config_change', 'live_lost']),
671
+ /**
672
+ * 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).
675
+ *
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"*.
683
+ *
684
+ * On a restatement this carries **when the state was first observed**, not
685
+ * when the frame was sent. A bridge that re-states a failure it has held for
686
+ * an hour says so.
687
+ */
688
+ observed_at_ms: z.number().int().nonnegative(),
689
+ /**
690
+ * Which request this frame answers (W6b), or `null` when it answers none.
691
+ *
692
+ * `null` is not a gap and must not be treated as one: a `cause: 'source'`
693
+ * frame — the unsolicited health report that makes a wrong password visible
694
+ * with nobody watching — answers no request by definition, and so does a
695
+ * `config_change` stop. Those are the majority of frames on a healthy
696
+ * system.
697
+ *
698
+ * A frame with `cause: 'command'` carries the `request_id` of the
699
+ * `camera_start` or `camera_stop` it answers. **The cloud resolves a
700
+ * pending attempt only on a matching id**, and drops a `command` frame
701
+ * whose id it no longer recognises rather than applying it to whatever is
702
+ * pending — a late answer to a cancelled attempt is stale, not current.
703
+ *
704
+ * **The pairing rule is not in this schema, deliberately.** "Non-null iff
705
+ * `cause === 'command'`" is a cross-field constraint; a zod `.refine()`
706
+ * 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
711
+ * where the correlation is used, in the cloud's bridge frame handler, and
712
+ * stated here so nobody has to derive it from that code.
713
+ */
714
+ request_id: z.string().min(1).max(64).nullable(),
715
+ });