@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
package/dist/config.js ADDED
@@ -0,0 +1,1988 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { z } from 'zod';
3
+ import { applyError, slug, rosName, rosTypeName, fieldPath, SLUG_RULE, ROS_NAME_RULE, ROS_TYPE_NAME_RULE, FIELD_PATH_RULE, } from './common.js';
4
+ /**
5
+ * `alertSeverity` is identical for the stored row and this document-nested
6
+ * definition — `z.enum(['warning', 'error'])`, nothing more to say twice —
7
+ * so it is imported rather than redefined. Not re-exported from here: the
8
+ * barrel already carries it from `alerts.ts`, and wave 4 moves the
9
+ * definition itself into this file once `alerts.ts` retires.
10
+ */
11
+ import { alertSeverity } from './alerts.js';
12
+ /**
13
+ * The exposure model (spec §4): what a developer configures per robot, how a
14
+ * configuration moves from draft to published, and how the cloud reports what
15
+ * it refuses.
16
+ *
17
+ * ## What this schema decides, and what it leaves to the cloud
18
+ *
19
+ * FL-002 names thirteen validation codes and this file implements some of
20
+ * them. The line was drawn four times while the format was written and never
21
+ * written down, so here it is.
22
+ *
23
+ * **Decided here** — everything a single entry, plus its own declared types,
24
+ * answers on its own: `unknown_key` (every object is this file's own
25
+ * `strictObject`, which is `z.strictObject` plus a missing key that names
26
+ * itself),
27
+ * `explicit_null`, `invalid_rate` (`rateThrottleHz`),
28
+ * `requires_single_field`, `invalid_condition`,
29
+ * `constraint_not_allowed_for_type`, `value_type_mismatch` and
30
+ * `failsafe_has_parameters`. The name grammar comes with the key, and
31
+ * `duplicate_slug` and `duplicate_parameter` come with the mapping — a
32
+ * repeated key is a YAML syntax error before any schema sees it.
33
+ *
34
+ * **Left to the cloud**, for one of two reasons:
35
+ *
36
+ * - *It needs introspection.* `unknown_topic`, `unknown_field_path`,
37
+ * `type_mismatch` and `requires_numeric_field` are all questions about the
38
+ * robot's own message definitions. This schema has no robot.
39
+ * - *It spans sections, or documents.* `reserved_slug` and cross-section
40
+ * `duplicate_slug` need the whole document; `undeclared_parameter`,
41
+ * `unused_parameter`, `unknown_message` and `nested_message_reference` need
42
+ * the index of declared names that `messages:` and each entry's
43
+ * `parameters:` build together.
44
+ *
45
+ * `nested_message_reference` is the one worth naming explicitly, because it
46
+ * looks decidable here and is: a `messages:` entry whose whole body is
47
+ * `'${name}'` is a nested reference, full stop. It is the cloud's anyway, so
48
+ * that all four name-resolution codes are answered in one place against one
49
+ * index. Splitting them would put one rule here and its three siblings there
50
+ * — the shape this file has twice had to undo.
51
+ *
52
+ * **Every refusal that answers one of the spec's codes carries
53
+ * `params: { code }`** with that code, which zod passes through `safeParse`
54
+ * untouched. The cloud maps an issue to a code and its repair by reading that
55
+ * field, never by matching the message prose — a join nobody notices
56
+ * breaking.
57
+ *
58
+ * Read the sentence narrowly, because a wider reading is false and was
59
+ * written here once. Plenty of refusals in this file carry no `params.code`,
60
+ * and correctly: the section caps (`parameterMap`'s fifty, `messageMap`'s two
61
+ * hundred), the camera device-path rules, and every refusal zod raises on its
62
+ * own — `unrecognized_keys` behind `unknown_key`, `too_big` behind
63
+ * `invalid_rate`. Those are not spec codes wearing a different hat; the cloud
64
+ * reaches them through zod's own issue codes. The one *spec* code with no
65
+ * `params` is a reversed pair of bounds — `min_value`/`max_value` on a
66
+ * parameter, `y_min`/`y_max` on a chart: `invalid_range` was deleted with
67
+ * `expected_range`, and no code replaced it.
68
+ *
69
+ * `robotConfigDoc` carries all six sections — messages, datapoints, actions,
70
+ * services, publishers and cameras — plus, since FL-002, the alerts, the
71
+ * chart bounds and the camera credentials that used to live outside it.
72
+ * Everything configurable about a robot is in this document, and there is one
73
+ * door to it. FL-002 rewrote the slug grammar (underscores, not dashes) and
74
+ * keyed every section by name; draft/publish and versioning are unchanged.
75
+ */
76
+ /**
77
+ * What every `pattern` in this document means, said in words.
78
+ *
79
+ * A developer whose topic name was wrong used to be shown the regular
80
+ * expression that refused it. These are the sentences that replace it — one
81
+ * per grammar rather than one per field, because the message explains why the
82
+ * *pattern* said no, and the same pattern says no for the same reason wherever
83
+ * it appears.
84
+ *
85
+ * **Four of the seven are not here.** `SLUG_RULE`, `ROS_NAME_RULE`,
86
+ * `ROS_TYPE_NAME_RULE` and `FIELD_PATH_RULE` belong to patterns `common.ts`
87
+ * declares, and each one lives beside its pattern so that the two cannot move
88
+ * apart; that file's header says why. The three below exist only inside a
89
+ * configuration document, so they are here, beside theirs, on the same rule.
90
+ *
91
+ * **Where they are read, and when.** Each is used twice: as the message **zod
92
+ * itself produces**, and as `patternErrorMessage` in the exported JSON Schema,
93
+ * which is a published artifact other tools validate against and which a person
94
+ * reads. One constant with two readers, never two strings that happen to agree
95
+ * — `config-zod-messages.test.ts` asserts the two readings are the same string
96
+ * at all 24 pattern positions the document has, because under D3 nothing
97
+ * consumes `patternErrorMessage` at runtime and an unwatched second spelling of
98
+ * a live rule drifts word for word, forever and invisibly.
99
+ *
100
+ * In the console `patternErrorMessage` is also the live pattern diagnostic
101
+ * **until wave 3 lands**: `useMonacoYaml.ts` still passes `validate: true`, so
102
+ * between task 7's artifacts and D3 these sentences are what monaco-yaml shows.
103
+ * D3 then turns that validation off, and from there the message a developer
104
+ * sees comes from this schema's own parser and from the cloud — the same
105
+ * sentence, which is the point of there being one.
106
+ *
107
+ * The tense matters because the two states look identical from inside this
108
+ * file. Whoever reads it after wave 3 should find a claim that was true when
109
+ * written and stayed true, not one that quietly became false.
110
+ *
111
+ * Each one states the rule in words and gives one example, and none of them
112
+ * quotes its own regex — `config-messages.test.ts` asserts that over every
113
+ * pattern in the exported document, not over the three below.
114
+ */
115
+ const RTSP_URL_RULE = 'The URL has to begin with `rtsp://` or `rtsps://` — `rtsp://cam-1.plant.local/stream1`. No other scheme is accepted: the bridge opens this with a library that would equally honour `file:`.';
116
+ const MJPEG_URL_RULE = 'The URL has to begin with `http://` or `https://` — `http://cam-1.plant.local/video.mjpg`. No other scheme is accepted: the bridge opens this with a library that would equally serve `file:`.';
117
+ const DEVICE_PATH_RULE = 'A capture device is a path under `/dev/`, and the character straight after it is a letter or a digit — `/dev/video0`, or a stable `/dev/v4l/by-id/...` symlink. Nothing outside `/dev/` is accepted: the string reaches OpenCV, which would as happily open an ordinary file.';
118
+ /**
119
+ * The key of every section, every parameter map and the alert map: a slug,
120
+ * carrying the grammar's sentence.
121
+ *
122
+ * **`slug` itself stays plain in `common.ts`, and the reason is blast radius.**
123
+ * Not metadata loss: `.meta()` on a clone *merges* with the parent's entry per
124
+ * key and resolves it lazily, measured against zod 4.4.3 and written up at
125
+ * `messageBody`'s own `.meta()` below — a later `description` on a use of
126
+ * `slug` would keep the sentence, not drop it.
127
+ *
128
+ * What that reach would cost was measured instead, by adding the one `.meta()`
129
+ * line to `slug` in a copy of `src/` and re-exporting every artifact under each
130
+ * schema's own `io`: **42 of the 159 published schema artifacts** would carry
131
+ * it, `bridge-hello`, `datapoint-frame`, `snapshot-header` and
132
+ * `bridge-camera-state` among them — protocol frames the bridge **vendors**
133
+ * under `bridge/test/contracts/schema/`, so rewording one sentence would become
134
+ * a re-vendor plus a `SOURCE.md` edit in another repo. As landed the same
135
+ * search finds **7**, all config-derived.
136
+ *
137
+ * Whether `vscode-json-languageservice` honours `patternErrorMessage` on a
138
+ * `propertyNames` schema at all is **not measured** — §1.3 measured a value
139
+ * position, not a key one. It ships for the same reason as the rest: the
140
+ * artifact is read by tools and by people.
141
+ */
142
+ const mapKey = slug.meta({ patternErrorMessage: SLUG_RULE });
143
+ /**
144
+ * One field, carrying the sentence it says when it is absent.
145
+ *
146
+ * **Three shapes of "this key is not here", and the first version of this
147
+ * helper caught one of them.** A walk over every required key of a fully
148
+ * populated document — `config-zod-messages.test.ts`, which is the guard that
149
+ * found it — says the format has 52 required-key positions and that 14 were
150
+ * still answering in zod's words:
151
+ *
152
+ * - `invalid_type`, the ordinary case: a string, a number, an object.
153
+ * - `invalid_value` from a `z.literal` or a `z.enum`. A missing `fleetless:`
154
+ * said `Invalid input: expected 1`; a parameter without a `type` recited all
155
+ * fifteen ROS primitives.
156
+ * - `invalid_union`. `alertCondition.fire_at` said `Invalid input` and nothing
157
+ * else, and a camera source without `kind` said `Invalid discriminator value`
158
+ * — where the input is not `undefined` at all but the object that lacks the
159
+ * key, which is why that branch is tested separately.
160
+ *
161
+ * So the rule is the fact rather than the code: **a required field whose input
162
+ * is absent is a missing key, whatever zod calls the refusal.** For a value
163
+ * present as an explicit `undefined` the sentence reads as "missing", which is
164
+ * what the format means by it: omission is this format's only spelling of "not
165
+ * set".
166
+ *
167
+ * **`z.unknown()` needs a wrapper before it can be given a sentence at all.**
168
+ * It accepts `undefined`, so zod marks the key required and raises its own
169
+ * `expected nonoptional, received undefined` — an issue it attributes to
170
+ * neither the field nor the object, so no error map of ours is consulted
171
+ * (measured). `z.nonoptional` puts a schema there that can carry one. The JSON
172
+ * Schema and the inferred type are byte-identical either way (measured, zod
173
+ * 4.4.3), and a field that is genuinely optional is `.optional()` and is
174
+ * skipped here — but it is **not** behaviourally free, and that is the one
175
+ * place this task changed what the format accepts: a required key *present*
176
+ * holding `undefined` is now refused where zod's internal check accepted it.
177
+ *
178
+ * That was ruled in deliberately rather than noticed later, because no route
179
+ * into this format can carry such a key — JSON drops it, YAML's `message:`
180
+ * yields `null`, and jsonb cannot represent it — and because the alternative
181
+ * leaves `expected nonoptional, received undefined` on `message`, a field
182
+ * developers write by hand. The three measurements, and the condition that
183
+ * would make them false, are written where they are asserted:
184
+ * `config-zod-messages.test.ts`, *"and no route into the format can carry
185
+ * one"*. Read that before changing this line.
186
+ *
187
+ * `clone` is the only way to add an `error` to a schema that is already built,
188
+ * and **it drops the schema's registry entry** — its `description`, its
189
+ * `examples`, every annotation this wave added, all of which live in
190
+ * `z.globalRegistry` keyed by the schema instance rather than in its
191
+ * definition. So the entry is read back and put on the clone. Measured on zod
192
+ * 4.4.3: `z.globalRegistry.get` resolves the whole `.meta()` parent chain into
193
+ * one object, so what is copied is what the export would have produced, and a
194
+ * later `.meta()` on the result merges with it as it did before. A wrapper that
195
+ * silently emptied every hover in the format would be the worst available way
196
+ * to improve one message.
197
+ *
198
+ * Two residuals, neither reachable in this file today and both worth knowing
199
+ * before it grows: `{ ...def }` is a shallow spread and `def.shape` is a
200
+ * **getter**, so the spread resolves every nested shape at module-eval time —
201
+ * a `z.lazy` or a forward reference added later would be resolved here before
202
+ * its cycle closed, and the JSON Schema export could still look identical. And
203
+ * the copied registry entry is the resolved merge with no parent link, so a
204
+ * `.meta()` called on an original field *after* `strictObject` consumed it
205
+ * would not reach the copy inside the document. Neither is reachable here, and
206
+ * not by inspection: this module evaluates top to bottom, so a forward
207
+ * reference in any shape would be a `ReferenceError` at import rather than a
208
+ * subtle export — the module loading at all is the measurement. The one
209
+ * `z.lazy` in `contracts` is `introspection.ts`'s `typeField`, which no shape
210
+ * in this file holds. And every `.meta()` here is applied before the shape is
211
+ * handed over, which is what the file reads like and what the export
212
+ * comparison would show if it were not.
213
+ *
214
+ * The inherited `error` is kept and deferred to, so this composes with a
215
+ * field that already carries one rather than replacing it.
216
+ */
217
+ const saysItIsMissing = (field) => {
218
+ const meta = z.globalRegistry.get(field);
219
+ const def = { ...field._zod.def };
220
+ const inherited = def.error;
221
+ const carrier = def.type === 'optional' || !field.safeParse(undefined).success
222
+ ? field
223
+ : z.nonoptional(field);
224
+ const carrierDef = carrier === field
225
+ ? def
226
+ : { ...carrier._zod.def };
227
+ carrierDef.error = (issue) => {
228
+ const key = issue.path?.[issue.path.length - 1];
229
+ if (typeof key === 'string' && absent(issue, key))
230
+ return `Missing required key \`${key}\`.`;
231
+ return typeof inherited === 'function' ? inherited(issue) : inherited;
232
+ };
233
+ const cloned = carrier.clone(carrierDef);
234
+ if (meta !== undefined)
235
+ z.globalRegistry.add(cloned, meta);
236
+ return cloned;
237
+ };
238
+ /**
239
+ * Whether an issue is one field's way of saying the key is not there.
240
+ *
241
+ * The second arm is the discriminated union: zod hands its error map the whole
242
+ * object and points the path at the discriminator, so `input` is not
243
+ * `undefined` and the first arm cannot see it. `Object.hasOwn` rather than
244
+ * `in`, on this project's own rule — a document's keys are chosen by a
245
+ * developer, and `constructor` satisfies the slug grammar.
246
+ */
247
+ const absent = (issue, key) => issue.input === undefined
248
+ || (issue.code === 'invalid_union'
249
+ && typeof issue.input === 'object'
250
+ && issue.input !== null
251
+ && !Object.hasOwn(issue.input, key));
252
+ /** Each field of a shape, carrying the sentence it says when it is missing. */
253
+ const namesItsAbsence = (shape) => Object.fromEntries(Object.entries(shape).map(([key, field]) => [key, saysItIsMissing(field)]));
254
+ /**
255
+ * Every object in this document is strict, and every required key of it says
256
+ * its own name when it is absent.
257
+ *
258
+ * **This is not `z.strictObject`** — it is this file's, wrapping it. The
259
+ * difference is the second half: `Invalid input: expected object, received
260
+ * undefined` was the whole of what a developer was told when a camera had no
261
+ * `source:` (design §1.5), naming neither the key nor the fact that it was
262
+ * required. monaco-yaml said `Missing property "source".` for the same
263
+ * document, and was right to.
264
+ *
265
+ * It has to be done a field at a time. zod attributes a missing key to the
266
+ * **field's own** schema — an `invalid_type` whose input is `undefined` — and
267
+ * an `error` on the containing object is never consulted for it; measured on
268
+ * zod 4.4.3, an error map on the object saw no such issue at all. So the
269
+ * sentence is attached to every field of every shape, here, in one place,
270
+ * rather than at the eighteen objects and hundred-odd fields it would
271
+ * otherwise have to be remembered at.
272
+ *
273
+ * **How "every" is enforced, because the first version of this comment said
274
+ * "every" and was wrong.** Six of the eighteen objects were written
275
+ * `z\n .strictObject({`, so `z.strictObject` never appeared on one line and a
276
+ * `grep` for it returned only prose. Twelve conversions read as eighteen, and
277
+ * eleven required keys — `datapoints.<slug>.topic` and `.type` among them,
278
+ * which is the commonest entry in the whole format — went on reciting the
279
+ * sentence §1.5 calls unusable. The claim was in the source, which is what the
280
+ * next person reads.
281
+ *
282
+ * What makes it true now is not this paragraph. It is
283
+ * `config-zod-messages.test.ts`'s *"a required key that is absent names
284
+ * itself"*: a walk of the exported schema's `required` arrays against a
285
+ * fully-populated document, `oneOf` branches resolved by their discriminator,
286
+ * deleting one key at a time and asserting the message names it. It reaches 52
287
+ * positions across 19 objects, and the count comes out of the walk rather than
288
+ * off a list — a required key added to the format later is swept the day it
289
+ * exists, and an object that skips this helper is red before it is merged.
290
+ */
291
+ const strictObject = (shape) => z.strictObject(namesItsAbsence(shape));
292
+ /**
293
+ * A map keyed by slugs, whose refused key says **which** key and **why**.
294
+ *
295
+ * `Invalid key in record` was the worst sentence in the format and it lands on
296
+ * the commonest beginner mistake — a section keyed `Battery` rather than
297
+ * `battery`. It named neither the key nor the grammar, and the grammar was
298
+ * sitting one level down, on the key schema's own issue, where nothing that
299
+ * renders a `safeParse` result ever looks: the cloud's 422 and the console both
300
+ * read the top-level `issues[].message` and nothing below it.
301
+ *
302
+ * So the sentence is composed from exactly that: the key, then the messages of
303
+ * the issues the key schema itself raised. A key too short says so; a key that
304
+ * breaks the grammar states the grammar, `SLUG_RULE` verbatim. Composing rather
305
+ * than restating is what keeps this from becoming a second wording of the rule
306
+ * the moment the rule is reworded.
307
+ *
308
+ * The path already carries the key (`datapoints.Battery`) and always did — the
309
+ * fix is the sentence, not the path — but a message that reads correctly on its
310
+ * own is what a diagnostic list, a 422 body and a hover all need.
311
+ */
312
+ const slugKeyed = (entry) => z.record(mapKey, entry, {
313
+ error: (issue) => {
314
+ if (issue.code !== 'invalid_key')
315
+ return undefined;
316
+ const why = issue.issues.map((inner) => inner.message).filter(Boolean).join(' ');
317
+ return why.length === 0 ? undefined : `\`${String(issue.input)}\` is not a valid name. ${why}`;
318
+ },
319
+ });
320
+ /**
321
+ * `enumDescriptions` built from a table keyed by the **value**, never written
322
+ * out as a positional array.
323
+ *
324
+ * The consuming key is positional — `enumDescriptions[i]` documents `enum[i]` —
325
+ * and that is the whole hazard. Fifteen sentences hand-aligned against
326
+ * `parameterType`'s declaration order would misalign in silence the day
327
+ * somebody regroups that list, which is a grouping rather than an order the
328
+ * format needs. It would misalign only in the published artifact, where
329
+ * nothing else looks.
330
+ *
331
+ * So the alignment is made unrepresentable rather than tested: the table is
332
+ * keyed by value, `Record<V, string>` makes a missing value a **typecheck**
333
+ * failure the day one is added, and the order comes from the enum's own
334
+ * `.options`. `config-messages.test.ts` still checks arity, non-emptiness and
335
+ * that the sentences differ from one another — what it cannot check, and says
336
+ * so, is whether a sentence is the *right* one for its value.
337
+ */
338
+ const describeValues = (values, table) => values.map((value) => table[value]);
339
+ /**
340
+ * One of the format's own parameter holes, `${name}`, written so that it
341
+ * survives a `defaultSnippets` insert. **The backslash is load-bearing and is
342
+ * not a typo to tidy away.**
343
+ *
344
+ * The two syntaxes collide. `defaultSnippets` bodies are inserted as LSP
345
+ * snippets, where `${1:front}` is a tab stop and `${speed}` is a *variable* —
346
+ * and an unknown variable is not left alone. Measured against
347
+ * monaco-editor 0.52.2's own `SnippetParser`, which is what the console runs:
348
+ *
349
+ * | body holds | the editor inserts |
350
+ * |---|---|
351
+ * | `x: ${speed}` | `x: ` — the hole is **deleted**, silently |
352
+ * | `x: \${speed}` | `x: ${speed}` |
353
+ *
354
+ * yaml-language-server emits body strings verbatim (`stringifyObject`'s
355
+ * replacer only strips a leading `^` and quotes `true`/`false`), so nothing
356
+ * between here and the snippet engine escapes it for us. A body that writes a
357
+ * parameter unescaped therefore offers a developer a publisher whose message
358
+ * has lost the very value a caller was meant to fill, which parses and is
359
+ * wrong — the worst available outcome for a hint the developer trusts.
360
+ */
361
+ const param = (name) => `\\\${${name}}`;
362
+ /**
363
+ * The same skeleton, offered one level out — at the section rather than at the
364
+ * entry.
365
+ *
366
+ * A section body is its entry body under one slug key: `datapoints:` offers
367
+ * `{ battery: … }`, and `battery: ▮` offers the `…`. Both positions are real
368
+ * and both were silent, but they are **one skeleton**, so each is authored once
369
+ * as a `Snippet` constant and wrapped here for the section — never copied.
370
+ * Two copies of one skeleton is the drift this wave caught three times in three
371
+ * reviews: a body inventing a value its sibling had already answered, under a
372
+ * label that still agreed. `config-snippets.test.ts` deep-compares the two
373
+ * positions rather than trusting this.
374
+ *
375
+ * **The slug key belongs to the wrapper, not to the skeleton**, because it
376
+ * differs per snippet — `battery_voltage` for a plain datapoint, `battery` for
377
+ * the numeric one. It therefore takes tab stop `${1}`, and an entry body's own
378
+ * stops are numbered from `${2}` throughout. At the entry position that leaves
379
+ * no `${1}` at all, which costs nothing — measured against
380
+ * monaco-editor 0.52.2's own `SnippetParser`, the version the console runs: it
381
+ * sorts placeholders by index and requires neither that they start at 1 nor
382
+ * that they be contiguous, so a body of `a: ${2:x}` visits `2` first, and one
383
+ * of `a: ${2:x}` / `b: ${5:y}` visits `2` then `5`.
384
+ */
385
+ const underSlug = (slugKey, snippet) => ({ ...snippet, body: { [slugKey]: snippet.body } });
386
+ /**
387
+ * What an exposed service *is*, in the developer's own words (§17).
388
+ *
389
+ * This is what `robot_describe` carries verbatim, so it is read by a model
390
+ * that has never seen this robot and cannot ask a follow-up question.
391
+ * `unit` and `range` already say what a number *is*; this says what it
392
+ * *means*.
393
+ *
394
+ * **It lives on the configuration rather than on the app, and that was a
395
+ * decision with a cost.** §17's own wording put the semantic descriptions in
396
+ * the MCP app; André moved them here on 2026-08-18 so that a description is
397
+ * written once per service and true for every app that reaches the robot,
398
+ * beside the other metadata. What is given up is real and should not be
399
+ * rediscovered as a bug: **two apps can no longer describe one service
400
+ * differently for two audiences.** §17 was reworded in the same wave rather
401
+ * than left contradicting this field.
402
+ *
403
+ * **`.optional()` and not `.nullable().default(null)`, deliberately.** The
404
+ * established shape in this file is a default — and every use of it has
405
+ * added an instance to a known contradiction: `.default()` publishes the
406
+ * field as **required** in the generated JSON Schema, because after parsing
407
+ * it is always present. That is recorded four times over in
408
+ * `scripts/export-schemas.ts`, whose fix (`io: 'input'`, applied per schema)
409
+ * is a judgement call across roughly sixty schemas plus a re-vendor and a
410
+ * re-pin in four repos. W7c's playbook said task 0 would do it; reading the
411
+ * measured blast radius — 90 artifacts, 436 deletions for the blanket
412
+ * version — said otherwise, at the start of a wave with five people blocked
413
+ * on this pin. So the field simply does not create a fifth instance:
414
+ * optional is optional in both modes, and *absent* is the single spelling of
415
+ * "not described". `.min(1)` keeps the empty string from becoming a second.
416
+ */
417
+ export const serviceDescription = z.string().min(1).max(2000).optional();
418
+ /**
419
+ * One parameter's prose, for the same reader as `serviceDescription` and
420
+ * under the same rules. Shorter, because it describes one field of one call
421
+ * rather than the call itself.
422
+ */
423
+ export const parameterDescription = z.string().min(1).max(500).optional();
424
+ /** The ROS 2 primitive field types, spelled as ROS 2 spells them. */
425
+ export const parameterType = z.enum([
426
+ 'bool', 'byte', 'char',
427
+ 'int8', 'uint8', 'int16', 'uint16', 'int32', 'uint32', 'int64', 'uint64',
428
+ 'float32', 'float64',
429
+ 'string', 'wstring',
430
+ ]);
431
+ const INTEGER_TYPES = new Set(['byte', 'char', 'int8', 'uint8', 'int16', 'uint16', 'int32', 'uint32', 'int64', 'uint64']);
432
+ const FLOAT_TYPES = new Set(['float32', 'float64']);
433
+ const STRING_TYPES = new Set(['string', 'wstring']);
434
+ /**
435
+ * One hole a caller fills, authored once for the two positions it is offered
436
+ * from: `parameters:` (under `underSlug`) and the value of one entry below it.
437
+ *
438
+ * `type` is a placeholder default rather than a choice, unlike the camera
439
+ * source's `type`, and for two reasons that are **not** "the editor offers the
440
+ * fifteen here anyway". It does not: after the insert this position holds
441
+ * `float64` as a selected tab stop, and Monaco does not open the suggest widget
442
+ * over one — nor as the developer types over the selection, since an unquoted
443
+ * YAML scalar tokenizes as `string` and the editor's default
444
+ * `quickSuggestions.strings` is false. The fifteen are an explicit Ctrl+Space
445
+ * away.
446
+ *
447
+ * The reasons that do hold: fifteen options is a list that would have to be
448
+ * maintained beside the enum, and the enum is the format's own answer — a
449
+ * snippet must not fork it, and any shorter list is a subset presented as the
450
+ * set. And a wrong pick here **is** reported: `type_mismatch` checks the
451
+ * declared type against the type at the template position, which is the half of
452
+ * the rule that settles it. That is the difference from the camera `type`,
453
+ * where a wrong pick is accepted by every layer and the bridge then delivers no
454
+ * frames with nothing objecting.
455
+ *
456
+ * The bounds are the point of the block — the field descriptions call them
457
+ * where a speed limit actually holds — so they are in the skeleton, at
458
+ * `min_value`/`max_value`'s own `examples`. They are coupled to `type`: tabbing
459
+ * `float64` to `string` makes them `constraint_not_allowed_for_type`, one line
460
+ * below the pick, in a message that names the value just chosen. That is
461
+ * deliberate, where omitting the bounds would leave the format's one
462
+ * enforcement point out of the hint that introduces it. **When the developer
463
+ * meets it is not today**: this is a `superRefine`, so it is not in the JSON
464
+ * Schema export and monaco-yaml cannot see it — until the console validates
465
+ * against `robotConfigDoc` itself, the refusal arrives on save rather than
466
+ * under the cursor.
467
+ *
468
+ * No `default`, so the parameter is required: `default` is the one field here
469
+ * with no `examples`, and "has no default" is the format's spelling of
470
+ * required, which is the honest thing for a skeleton to start from. That every
471
+ * declared parameter must also appear in the `message` is a question about the
472
+ * whole entry and is the cloud's, not this schema's.
473
+ */
474
+ const PARAMETER_SNIPPET = {
475
+ label: 'a parameter, with its bounds',
476
+ description: 'One hole a caller fills: what type it is, what values it may take, and what it means. Without a `default` it is required, and the bounds are enforced in the cloud before anything reaches the robot.',
477
+ body: {
478
+ type: '${2:float64}',
479
+ min_value: -0.5,
480
+ max_value: 0.5,
481
+ description: '${3:What a caller is choosing when they set this.}',
482
+ },
483
+ };
484
+ /**
485
+ * One parameter a caller may fill in a message template.
486
+ *
487
+ * `type` is required and **not derived from the template position**, even
488
+ * though introspection usually knows it. The reason is the robot that has
489
+ * never connected: there is nothing to derive there, and that is exactly
490
+ * where the editor has to help most.
491
+ *
492
+ * Which constraints exist depends on the type, and a constraint on the wrong
493
+ * type is refused rather than silently inert. Floats deliberately have no
494
+ * `enum`: equality on floating point is unreliable, so an enumerated float
495
+ * list is a trap that only shows up in operation.
496
+ *
497
+ * **A `default` and every `enum` entry must match `type`**, and that is
498
+ * decided here rather than in the cloud. Its sibling
499
+ * `constraint_not_allowed_for_type` was always here, and leaving one of a
500
+ * pair in zod and the other in the cloud is two policies for one decision.
501
+ * It needs nothing this schema does not have: a value and a declared type.
502
+ * `type_mismatch` — the declared type against the type at the template
503
+ * position — is the one that needs introspection, and it is the cloud's.
504
+ *
505
+ * There is no `required` field. A placeholder cannot be left unfilled, so
506
+ * "required" is exactly "has no `default`" — a second spelling of one fact
507
+ * is the defect this file has spent two waves removing.
508
+ */
509
+ export const parameterSpec = strictObject({
510
+ type: parameterType.meta({
511
+ description: 'The ROS 2 primitive a value of this parameter must be, spelled the way ROS 2 spells it — `float64`, not `double`. It **decides which other constraints are allowed at all**: `min_value` and `max_value` need a numeric type, `regex` needs a string one, and a constraint on the wrong type is refused rather than quietly ignored.',
512
+ /**
513
+ * One sentence per value. The field's own paragraph is already the
514
+ * hover; these answer the different question the editor asks when the
515
+ * cursor is on **one** offer — what is this type, and what does choosing
516
+ * it allow. Written for the value, so `int32` states its range and
517
+ * `string` says it is the type a `regex` may constrain.
518
+ */
519
+ enumDescriptions: describeValues(parameterType.options, {
520
+ bool: 'A `true`/`false` flag. The one type that takes no constraint at all: no bounds, no `regex`, no `enum`.',
521
+ byte: 'One raw octet, `0` to `255`, carrying no character meaning. It counts as an integer here, so bounds and an `enum` apply to it.',
522
+ char: 'A single-octet character code, `0` to `255`. ROS 2 keeps it apart from `byte` although the width is the same, and it travels as a number rather than as a one-character string.',
523
+ int8: 'A whole number from `-128` to `127`.',
524
+ uint8: 'A whole number from `0` to `255`.',
525
+ int16: 'A whole number from `-32768` to `32767`.',
526
+ uint16: 'A whole number from `0` to `65535`.',
527
+ int32: 'A whole number from `-2147483648` to `2147483647` — the usual choice for a count or an index.',
528
+ uint32: 'A whole number from `0` to `4294967295`.',
529
+ int64: 'A whole number from `-9223372036854775808` to `9223372036854775807`.',
530
+ uint64: 'A whole number from `0` to `18446744073709551615`.',
531
+ float32: 'A single-precision number, roughly seven significant digits.',
532
+ float64: 'A double-precision number, roughly fifteen significant digits. This is what other languages call `double`; ROS 2 spells it `float64` and so does this field.',
533
+ string: 'Text, carried as UTF-8. One of the two types a `regex` may constrain.',
534
+ wstring: 'Text as wide characters, and rare — nearly every ROS 2 interface uses `string`. It takes a `regex` on the same terms.',
535
+ }),
536
+ }),
537
+ default: z.union([z.number(), z.string(), z.boolean()]).meta({
538
+ description: 'The value used when a caller omits this parameter: **without a `default` the parameter is required**, because the message cannot be built without it. It must itself satisfy `min_value`, `max_value`, `enum` and `regex` — a default the constraints reject is refused here rather than becoming the one value that reaches the robot unchecked.',
539
+ }).optional(),
540
+ min_value: z.number().meta({
541
+ description: 'The lowest value a caller may send; numeric types only. It is **enforced in the cloud, before anything reaches the robot** — this is where a speed limit actually holds, rather than in the app that is supposed to respect it.',
542
+ examples: [-0.5],
543
+ }).optional(),
544
+ max_value: z.number().meta({
545
+ description: 'The highest value a caller may send; numeric types only, and it may not sit below `min_value`. A reversed pair is refused at parse time, because nothing downstream catches it and every call would then fail against a bound no value can satisfy.',
546
+ examples: [0.5],
547
+ }).optional(),
548
+ enum: z.array(z.union([z.string(), z.number()])).min(1).meta({
549
+ description: 'The complete set of values a caller may send. Integer and string types only — **never a float**, because equality on floating point is unreliable and an enumerated float list is a trap that only shows up in operation. Every entry must match `type`, and a `default` must be one of them.',
550
+ }).optional(),
551
+ regex: z.string().min(1).meta({
552
+ description: 'A pattern the value must match; string types only. It is compiled as a JavaScript regular expression and is **not anchored**, so `[a-z]+` accepts any value that merely contains a lowercase run — a pattern meant to cover the whole value writes its own `^` and `$`.',
553
+ examples: ['^[a-z_]+$'],
554
+ }).optional(),
555
+ description: parameterDescription.meta({
556
+ description: 'What this parameter means, in the developer\'s own words, and documentation only — the robot does nothing with it. It travels into the input schema `robot_describe` publishes for this call, beside the bounds, so `type` and the range say what the value *is* and this is the only place that says what it *does*.',
557
+ /** The sentence `PARAMETER_SNIPPET` already places here, verbatim. */
558
+ examples: ['What a caller is choosing when they set this.'],
559
+ }),
560
+ })
561
+ .superRefine((p, ctx) => {
562
+ const numeric = INTEGER_TYPES.has(p.type) || FLOAT_TYPES.has(p.type);
563
+ const refuse = (path, why, code) => ctx.addIssue({ code: 'custom', path, message: why, ...(code ? { params: { code } } : {}) });
564
+ /**
565
+ * What a value of this parameter's declared type may look like on the
566
+ * wire. Integers are checked with `Number.isInteger`, which cannot tell
567
+ * `1.0` from `1` — nothing can, in JSON or in YAML, since both parse to
568
+ * the same double. `1.5` on an `int32` is the case worth catching and it
569
+ * is caught.
570
+ */
571
+ const matchesType = (v) => {
572
+ if (p.type === 'bool')
573
+ return typeof v === 'boolean';
574
+ if (STRING_TYPES.has(p.type))
575
+ return typeof v === 'string';
576
+ if (INTEGER_TYPES.has(p.type))
577
+ return typeof v === 'number' && Number.isInteger(v);
578
+ return typeof v === 'number' && Number.isFinite(v);
579
+ };
580
+ if (!numeric && (p.min_value !== undefined || p.max_value !== undefined))
581
+ refuse(['min_value'], `min_value/max_value need a numeric type, not '${p.type}'`, 'constraint_not_allowed_for_type');
582
+ if (!STRING_TYPES.has(p.type) && p.regex !== undefined)
583
+ refuse(['regex'], `regex needs a string type, not '${p.type}'`, 'constraint_not_allowed_for_type');
584
+ if (p.enum !== undefined && !(INTEGER_TYPES.has(p.type) || STRING_TYPES.has(p.type))) {
585
+ refuse(['enum'], `enum needs an integer or string type, not '${p.type}'`, 'constraint_not_allowed_for_type');
586
+ }
587
+ else if (p.enum !== undefined) {
588
+ /**
589
+ * Only reached when `enum` is allowed at all. A float `enum` is one
590
+ * mistake, not two: reporting both codes for it would leave neither
591
+ * pinned to a document that produces exactly it.
592
+ */
593
+ p.enum.forEach((v, i) => {
594
+ if (!matchesType(v))
595
+ refuse(['enum', i], `enum entry does not match type '${p.type}'`, 'value_type_mismatch');
596
+ });
597
+ }
598
+ if (p.default !== undefined && !matchesType(p.default))
599
+ refuse(['default'], `default does not match type '${p.type}'`, 'value_type_mismatch');
600
+ /**
601
+ * One of the two refusals in this file with no `params.code`, the other
602
+ * being `datapointChart`'s: `invalid_range` was deleted with
603
+ * `expected_range`, and reversed bounds are not one of the thirteen.
604
+ * Inventing a fourteenth here would put a code in the contracts that the
605
+ * cloud's table does not know.
606
+ */
607
+ if (p.min_value !== undefined && p.max_value !== undefined && p.min_value > p.max_value)
608
+ refuse(['min_value'], 'min_value is greater than max_value');
609
+ /**
610
+ * A default must satisfy the same constraints a caller's value must.
611
+ *
612
+ * Without this, a default is the one way past bounds that are otherwise
613
+ * the enforcement point — the spec calls `min_value`/`max_value` "the
614
+ * speed limit that actually holds", enforced in the cloud before anything
615
+ * reaches the robot. But a caller who simply omits the parameter gets the
616
+ * default, and the bridge fills it at the template walk without
617
+ * re-checking bounds, deliberately: a second enforcement point there
618
+ * would be the weaker of two policies. So `{min_value: -1, max_value: 1,
619
+ * default: 99}` published 99 to a robot with no layer objecting.
620
+ *
621
+ * No `params.code`, for the same reason as the reversed bounds above.
622
+ */
623
+ if (p.default !== undefined && matchesType(p.default)) {
624
+ const d = p.default;
625
+ if (typeof d === 'number') {
626
+ if (p.min_value !== undefined && d < p.min_value)
627
+ refuse(['default'], `default ${d} is below min_value ${p.min_value}`);
628
+ if (p.max_value !== undefined && d > p.max_value)
629
+ refuse(['default'], `default ${d} is above max_value ${p.max_value}`);
630
+ }
631
+ if (p.enum !== undefined && !p.enum.some((v) => v === d))
632
+ refuse(['default'], 'default is not one of the enum entries');
633
+ if (p.regex !== undefined && typeof d === 'string') {
634
+ let re;
635
+ try {
636
+ re = new RegExp(p.regex);
637
+ }
638
+ catch {
639
+ // An unparseable regex is its own problem and not this check's to
640
+ // report; skip rather than refuse the default for it.
641
+ }
642
+ if (re && !re.test(d))
643
+ refuse(['default'], 'default does not match regex');
644
+ }
645
+ }
646
+ })
647
+ /**
648
+ * The value position of one entry — `speed: ▮` under `parameters:`. It is
649
+ * reached by a developer adding a **second** parameter by hand, which the
650
+ * section snippet never covers: that one fires on the empty `parameters:` and
651
+ * not again.
652
+ */
653
+ .meta({ defaultSnippets: [PARAMETER_SNIPPET] });
654
+ /** Parameters of one entry, keyed by name. At most 50. */
655
+ export const parameterMap = slugKeyed(parameterSpec)
656
+ .refine((m) => Object.keys(m).length <= 50, { message: 'at most 50 parameters per entry' })
657
+ .meta({
658
+ description: 'The holes in this entry\'s `message` that a caller fills, keyed by **parameter name** rather than by field path — so the name survives the field moving inside the message, and a caller sends something that means what it says. Every declared parameter must appear somewhere in the message and every `${name}` in the message must be declared; either half alone is an error.',
659
+ /**
660
+ * **One snippet reaching three positions.** `parameters:` under an action,
661
+ * under a service and under a publisher are all this node, so the snippet
662
+ * is authored once here rather than three times on the three sections.
663
+ * Three copies that must agree is three chances to disagree, and the
664
+ * export inlines this object into all three positions — which
665
+ * `config-snippets.test.ts` asserts rather than assumes, because the way
666
+ * this comes apart is somebody later giving one section a `parameters:`
667
+ * snippet of its own.
668
+ *
669
+ * The body is `PARAMETER_SNIPPET` under its slug key — the same skeleton
670
+ * the entry position below offers, and the reasons behind every value in
671
+ * it are written there.
672
+ */
673
+ defaultSnippets: [underSlug('${1:speed}', PARAMETER_SNIPPET)],
674
+ });
675
+ /**
676
+ * Slugs no configured entry may take (spec §4.3), across **all five exposure
677
+ * sections at once** — slugs are one namespace, so a name reserved here is
678
+ * reserved everywhere.
679
+ *
680
+ * The first three are built-ins: the cloud or the bridge already publishes
681
+ * something under them, so a configured entry would be a second producer for
682
+ * one name. `history` is reserved for a different reason and is not a built-in
683
+ * — nothing publishes it. `GET /api/robots/:id/jobs/history` is a **literal
684
+ * sibling** of `GET /api/robots/:id/jobs/:slug`, so an action or service named
685
+ * `history` would have a job route no caller could ever reach. Reserving the
686
+ * name is the honest half of that: the alternative is a slug the format
687
+ * accepts and one route silently cannot address.
688
+ *
689
+ * **This constant is the only list.** The cloud's `validation.ts` builds its
690
+ * set from it and emits `reserved_slug`; `config-store.ts` reads it for the
691
+ * rename target; the console reads it for slug suggestion and repairs. Nothing
692
+ * copies the members. Note that it is NOT the enumeration of built-in
693
+ * datapoints — the cloud keeps that separately, and it must, now that a
694
+ * reserved name exists that no plane serves.
695
+ *
696
+ * **What it does not do: `robotConfigDoc` does not enforce it.** Reservation is
697
+ * a semantic check that belongs with the ones that need the robot's context,
698
+ * and it answers as a `ValidationIssue` carrying the section, the slug and a
699
+ * severity — which a zod issue could not, and which is what the console's
700
+ * repair actions read. The section descriptions below therefore say "refused
701
+ * when the document is validated" rather than "refused here" — the artifact
702
+ * ships those sentences to readers who have only the JSON Schema, and
703
+ * "here" would have promised them a refusal this parse does not make.
704
+ */
705
+ export const RESERVED_SLUGS = ['bridge_state', 'robot_details', 'bridge_pressure', 'history'];
706
+ /**
707
+ * When an alert fires and when it is ok again. There is no discriminator:
708
+ * `resolve_at` absent means equality, present means a threshold whose
709
+ * direction follows from the comparison. The gap is the hysteresis, and it
710
+ * is therefore mandatory for thresholds — a value sitting exactly on a
711
+ * threshold with no gap flips on every sample.
712
+ */
713
+ export const alertCondition = strictObject({
714
+ fire_at: z.union([z.number().finite(), z.string(), z.boolean()]).meta({
715
+ description: 'The value at which the alert starts firing. Alone it is an **equality**: it fires while the value equals `fire_at` and is ok again as soon as it differs, which is what makes a boolean or a string condition meaningful. Adding `resolve_at` turns it into a threshold instead.',
716
+ examples: [15, true],
717
+ }),
718
+ resolve_at: z.number().finite().meta({
719
+ description: 'The value at which a firing alert becomes ok again — allowed only when `fire_at` is a number, and it **must differ from it**. That gap is the hysteresis, and it makes the condition a threshold whose direction follows from which of the two values is higher. Without a gap a value sitting on the line flips on every sample.',
720
+ examples: [18],
721
+ }).optional(),
722
+ })
723
+ .superRefine((c, ctx) => {
724
+ if (c.resolve_at === undefined)
725
+ return;
726
+ if (typeof c.fire_at !== 'number')
727
+ ctx.addIssue({
728
+ code: 'custom',
729
+ path: ['resolve_at'],
730
+ message: 'resolve_at is only allowed when fire_at is a number',
731
+ params: { code: 'invalid_condition' },
732
+ });
733
+ else if (c.resolve_at === c.fire_at)
734
+ ctx.addIssue({
735
+ code: 'custom',
736
+ path: ['resolve_at'],
737
+ message: 'resolve_at must differ from fire_at',
738
+ params: { code: 'invalid_condition' },
739
+ });
740
+ });
741
+ /**
742
+ * The four defaults the format names, as constants.
743
+ *
744
+ * **The fields stay `.optional()`, not `.default()`** — that argument is on
745
+ * `serviceDescription` above and has not changed: `.default()` publishes a
746
+ * field as *required* in the generated JSON Schema, and absence is the
747
+ * single spelling of "not set" in this format. What was missing is the
748
+ * number itself. Left only in prose, the cloud and the console each invent
749
+ * their own, and the two agree until one of them is edited. The house answer
750
+ * is a named constant, so a consumer applying a default reads it from here.
751
+ */
752
+ export const ALERT_SEVERITY_DEFAULT = 'warning';
753
+ export const ALERT_ENABLED_DEFAULT = true;
754
+ /** How often a value is written to history — not how often it is sent. */
755
+ export const RETENTION_INTERVAL_SECONDS_DEFAULT = 300;
756
+ /** The window a chart opens on, in minutes. Display only. */
757
+ export const CHART_WINDOW_MINUTES_DEFAULT = 60;
758
+ /**
759
+ * One whole alert, authored once for the two positions it is offered from:
760
+ * `alerts:` (under `underSlug`) and the value of one entry below it.
761
+ *
762
+ * **The body carries a condition.** `condition` is `datapointAlert`'s only
763
+ * required field, so a skeleton that stopped at the key would insert a document
764
+ * the format refuses — the one outcome worse than offering nothing, because the
765
+ * developer trusts the hint. It is also one decision for a reader: an alert
766
+ * without a condition is not a partial alert, it is nothing. The separate
767
+ * snippets on `condition` itself still earn their place — they are what a
768
+ * developer gets when they come back to an existing alert and rewrite the
769
+ * threshold, where this one is never offered.
770
+ *
771
+ * `severity` is left out: it is optional, absent means `warning`, and it
772
+ * changes no behaviour at all. Writing it would add a line that decides
773
+ * nothing. `enabled` likewise — absent means on, which is what an alert
774
+ * somebody just wrote is for.
775
+ */
776
+ const ALERT_SNIPPET = {
777
+ label: 'an alert, with its condition',
778
+ description: 'One whole alert: the label shown in place of its key, and the threshold with the gap that keeps it from flipping on every sample.',
779
+ body: {
780
+ condition: { fire_at: 15, resolve_at: 18 },
781
+ name: '${2:Battery low}',
782
+ },
783
+ };
784
+ /**
785
+ * An alert's definition. Runtime state — whether it is firing, since when,
786
+ * with what value — is NOT here: it lives in the database and survives a
787
+ * restart, and it has no business in a versioned document.
788
+ *
789
+ * `severity` and `enabled` are absent-means-`ALERT_SEVERITY_DEFAULT` and
790
+ * absent-means-`ALERT_ENABLED_DEFAULT`; see those constants for why the
791
+ * default is not applied here.
792
+ */
793
+ export const datapointAlert = strictObject({
794
+ condition: alertCondition.meta({
795
+ description: 'When this alert fires and when it is ok again. It carries **no discriminator**: upper threshold, lower threshold or equality all follow from the two values in it. Editing it resets the alert to `ok` on the next publish, while a publish that leaves it untouched keeps the running state.',
796
+ /**
797
+ * **Two snippets, and the reason is the missing discriminator.** A
798
+ * threshold and an equality are the same shape here — one field apart —
799
+ * so a single skeleton would not merely be incomplete, it would hide one
800
+ * of the two things this field exists to express behind a `resolve_at`
801
+ * the developer has to know to delete. Offering both makes the choice the
802
+ * schema deliberately does not name into a choice the editor does.
803
+ *
804
+ * Both values come from `fire_at`'s own `examples`, which carry exactly
805
+ * this pair: `15` for the threshold and `true` for the equality.
806
+ *
807
+ * **The second snippet costs the `condition:` *key* completion its body,
808
+ * and that price is paid knowingly.** monaco-yaml's
809
+ * `getInsertTextForProperty` (`yaml.worker.js:8520`) takes
810
+ * `defaultSnippets[0].body` only when a node carries **exactly one**
811
+ * snippet, so accepting `condition` from the key list writes the bare key
812
+ * here where every other node this wave touched writes its whole block.
813
+ * The two stay anyway: the value position — a developer who has written
814
+ * `condition:` and pressed ⏎ — is where the question "what goes here?" is
815
+ * actually asked, and that is the position this wave exists to answer.
816
+ * Merging them into one would buy back the key completion by deleting the
817
+ * choice the schema deliberately does not name, which is the worse trade;
818
+ * anyone tempted to make it should change the key-completion behaviour
819
+ * knowingly rather than as a side effect of tidying two snippets into one.
820
+ */
821
+ defaultSnippets: [
822
+ {
823
+ label: 'a threshold, with its hysteresis',
824
+ description: 'Fires below 15 and is ok again above 18. The gap is what keeps a value sitting on the line from flipping on every sample; the direction follows from which of the two is higher, and nothing else declares it.',
825
+ body: { fire_at: 15, resolve_at: 18 },
826
+ },
827
+ {
828
+ label: 'an equality',
829
+ description: 'Fires while the value equals `fire_at` and is ok as soon as it differs — the only form a boolean or a string condition can take. No `resolve_at`: adding one would turn this into a threshold, and is refused unless `fire_at` is a number.',
830
+ body: { fire_at: true },
831
+ },
832
+ ],
833
+ }),
834
+ severity: alertSeverity.meta({
835
+ description: 'How bad it is when this alert fires; absent means `warning`. It changes no behaviour — nothing is escalated, retried or delivered differently — it travels with the org event and colours the alert wherever it is shown.',
836
+ enumDescriptions: describeValues(alertSeverity.options, {
837
+ warning: 'Worth seeing. This is what an alert that names no severity gets.',
838
+ error: 'Worth acting on. The only difference from `warning` is how the alert is shown: the same event is written, at the same moment, to the same places.',
839
+ }),
840
+ }).optional(),
841
+ name: z.string().min(1).max(120).meta({
842
+ description: 'A human-readable label shown wherever this alert appears, in place of its bare key. It is not the alert\'s identity — the key is — so the label can be reworded freely, while changing the key deletes one alert and creates another.',
843
+ examples: ['Battery low'],
844
+ }).optional(),
845
+ enabled: z.boolean().meta({
846
+ description: 'Whether this alert is evaluated at all. Absent means on, the opposite of `retention.enabled`: an alert that is written down watches unless it is explicitly switched off, which is how one is silenced without losing the key that identifies it.',
847
+ }).optional(),
848
+ }).meta({
849
+ /**
850
+ * The value position of one entry — `battery_low: ▮` under `alerts:`, which
851
+ * is where a developer adding a **second** alert by hand stands. The section
852
+ * snippet fires on the empty `alerts:` and never again.
853
+ */
854
+ defaultSnippets: [ALERT_SNIPPET],
855
+ });
856
+ /** Requires a numeric field — all four fields share that one precondition. */
857
+ export const datapointNumeric = strictObject({
858
+ scale: z.number().meta({
859
+ description: 'A factor the robot multiplies the raw value by before sending it (`value * scale + offset`). The arithmetic happens once, at the source, so REST, realtime and history can never disagree about a number.',
860
+ examples: [100],
861
+ }).optional(),
862
+ offset: z.number().meta({
863
+ description: 'A constant the robot adds after `scale` (`value * scale + offset`), for a value whose zero sits in the wrong place. Like `scale` it is applied before sending, so history stores the converted value and a later correction cannot reach what is already stored.',
864
+ examples: [-273.15],
865
+ }).optional(),
866
+ unit: z.string().max(32).meta({
867
+ description: 'The unit of the value **after** `scale` and `offset`, not the robot\'s own. It is shown beside the value and carried by `robot_describe` as its own field, so a model does not have to guess whether 15 means percent, volts or minutes.',
868
+ examples: ['%'],
869
+ }).optional(),
870
+ decimals: z.number().int().min(0).max(6).meta({
871
+ description: 'How many fraction digits the console shows the value with — value tile, chart axis and tooltip, and the datapoint detail page — and the number `robot_describe` reports as its own field, so a model formats the value the way the console does. Presentation only: the stored value keeps the precision it arrived with, and absent means the console\'s own default rather than zero.',
872
+ examples: [1],
873
+ }).optional(),
874
+ });
875
+ /**
876
+ * `interval_seconds` absent means `RETENTION_INTERVAL_SECONDS_DEFAULT`.
877
+ *
878
+ * **`enabled` absent means off**, and that direction is the deliberate one.
879
+ * Stored points are what a customer is billed for, so a default that silently
880
+ * turned history on would start charging for a value nobody asked to keep. The
881
+ * cheap mistake is a developer noticing a datapoint has no history and
882
+ * switching it on; the expensive one is nobody noticing that everything has
883
+ * history. Absent-means-off is also what the cloud already does — this comment
884
+ * exists because it was doing it without anything saying so.
885
+ *
886
+ * Not spelled `.default(false)` for the same reason as every other default in
887
+ * this file: the document a developer wrote is the document that is stored,
888
+ * and a parse that inserts fields makes the round trip a lie.
889
+ */
890
+ export const datapointRetention = strictObject({
891
+ enabled: z.boolean().meta({
892
+ description: 'Whether values are written to the time series and become queryable. Off by default: without it the value is live only, and nobody who was not watching will ever see it.',
893
+ }).optional(),
894
+ interval_seconds: z.number().int().min(1).max(3600).meta({
895
+ description: 'How often a value is written to history, in seconds; absent means `300`. **Not** how often it is sent — that is `rate_throttle_hz`. Stored points are billed, so this is the direct lever on what a robot costs, and a bumper that is true for 200 ms does not appear unless a write falls inside it.',
896
+ examples: [300, 60],
897
+ }).optional(),
898
+ max_buffer_values: z.number().int().min(1).max(100_000).meta({
899
+ description: 'How many values the robot holds while the bridge is disconnected, to be pushed once it reconnects. The catch-up runs behind live telemetry and job results at a limited rate, so closing a gap never delays the present; without it the series simply has a gap, which is an honest answer.',
900
+ examples: [5000],
901
+ }).optional(),
902
+ });
903
+ /**
904
+ * Chart display, and display only. `default_window_minutes` absent means
905
+ * `CHART_WINDOW_MINUTES_DEFAULT`.
906
+ *
907
+ * The bounds are ordered here for the same reason `parameterSpec`'s are:
908
+ * `{y_min: 10, y_max: 1}` is a mistake nothing else catches. It used to be
909
+ * `invalid_range`'s job and that code was deleted with `expected_range`, so
910
+ * without this the document would carry a reversed axis all the way to a
911
+ * chart that renders empty.
912
+ */
913
+ /**
914
+ * Named rather than inlined at `style:`, so that `describeValues` can read its
915
+ * `.options` and its per-value sentences can be keyed by value.
916
+ */
917
+ const chartStyle = z.enum(['line', 'step']);
918
+ export const datapointChart = strictObject({
919
+ y_min: z.number().finite().meta({
920
+ description: 'A fixed floor for the chart\'s y axis; omitted, the axis scales to the data. `0` is a real floor and is read as `0`, never as unset.',
921
+ examples: [0],
922
+ }).optional(),
923
+ y_max: z.number().finite().meta({
924
+ description: 'A fixed ceiling for the chart\'s y axis; omitted, the axis scales to the data. It may not sit below `y_min`: a reversed pair is refused here because nothing downstream catches it, and the chart would render empty.',
925
+ examples: [100],
926
+ }).optional(),
927
+ style: chartStyle.meta({
928
+ description: 'How the drawing joins two samples, which is not a matter of taste. `line` claims the value moved evenly between them, roughly true of a temperature or a charge; `step` holds and then jumps, the only honest drawing for a mode, a switch or a counter, where a straight line would show values that never existed.',
929
+ enumDescriptions: describeValues(chartStyle.options, {
930
+ line: 'Straight lines between samples, so the drawing claims the value moved evenly from one to the next. Right for a quantity that really is continuous — a temperature, a charge level — where a reading taken between two samples would have landed somewhere on that line.',
931
+ step: 'Each value is held until the next one arrives, then jumps to it. Right for anything that does not slide between its values — a mode, a state, a switch, a counter — where a sloped line would draw readings the robot never reported.',
932
+ }),
933
+ }).optional(),
934
+ default_window_minutes: z.number().int().min(1).max(43_200).meta({
935
+ description: 'How far back the chart reaches when it is first opened, in minutes; absent means `60`. Only the starting zoom: a viewer may look further, and nothing about what is stored follows from it.',
936
+ examples: [1440],
937
+ }).optional(),
938
+ })
939
+ .superRefine((c, ctx) => {
940
+ if (c.y_min !== undefined && c.y_max !== undefined && c.y_min > c.y_max)
941
+ ctx.addIssue({ code: 'custom', path: ['y_min'], message: 'y_min is greater than y_max' });
942
+ });
943
+ /**
944
+ * The ceiling lives here once. `rest.ts`'s `datapointDescriptor` reuses it,
945
+ * so the two cannot drift apart the way a number spelled out twice always
946
+ * eventually does. `0` is deliberately admitted — zero and "omitted"
947
+ * (`datapointConfig`) or `null` (`datapointDescriptor`) are the same fact,
948
+ * "no throttling", not a refused value: `.positive()` here would exclude
949
+ * the very thing this field's own absence already means.
950
+ */
951
+ export const rateThrottleHz = z.number().nonnegative().max(20);
952
+ /**
953
+ * One value the robot publishes, authored once for the two positions it is
954
+ * offered from: `datapoints:` (under `underSlug`) and the value of one entry
955
+ * below it. The four required-to-be-useful fields and nothing else; everything
956
+ * optional arrives by ordinary key completion, which works and never stopped.
957
+ */
958
+ const DATAPOINT_SNIPPET = {
959
+ label: 'a datapoint',
960
+ description: 'One value the robot publishes: one field of one topic.',
961
+ body: {
962
+ topic: '${2:/battery}',
963
+ type: '${3:sensor_msgs/msg/BatteryState}',
964
+ field: '${4:voltage}',
965
+ description: '${5:What this value is, for whoever meets it in the console.}',
966
+ },
967
+ };
968
+ /**
969
+ * The same, with the three sub-blocks a plain datapoint leaves out — what the
970
+ * number means, what is kept of it, and how it is drawn.
971
+ *
972
+ * `chart.style` is the literal `line` and not the choice the `chart:` node
973
+ * itself offers: this body is a battery percentage, where `line` is not a
974
+ * guess.
975
+ */
976
+ const NUMERIC_DATAPOINT_SNIPPET = {
977
+ label: 'a numeric datapoint, with history and a chart',
978
+ description: 'A number with its unit, what is kept of it and how it is drawn — the blocks a plain datapoint leaves out.',
979
+ body: {
980
+ topic: '${2:/battery}',
981
+ type: '${3:sensor_msgs/msg/BatteryState}',
982
+ field: '${4:percentage}',
983
+ description: '${5:What this value is, for whoever meets it in the console.}',
984
+ /**
985
+ * The quotes inside `unit` are part of the inserted text and are not
986
+ * decoration. A body string is written into the document verbatim, and `%`
987
+ * is a YAML directive indicator: measured with `yaml` 2.9.0, `unit: %` is a
988
+ * **syntax error** ("Plain value cannot start with directive indicator
989
+ * character %") while `unit: "%"` parses to `%`. Nothing between here and
990
+ * the buffer quotes a scalar for us.
991
+ */
992
+ numeric: { scale: 100, unit: '"%"', decimals: 1 },
993
+ retention: { enabled: true, interval_seconds: 300 },
994
+ chart: { y_min: 0, y_max: 100, style: 'line' },
995
+ },
996
+ };
997
+ /**
998
+ * One exposed datapoint: one field of a topic, or the whole topic
999
+ * (`field` omitted). Never several topics.
1000
+ *
1001
+ * `rate_throttle_hz` is an upper bound, not a clock — the bridge drops what
1002
+ * arrives too fast and never repeats a value to manufacture a rate. The
1003
+ * ceiling is 20: an app's surface has no use for more, and a control loop
1004
+ * belongs on a tool that reads at the robot.
1005
+ */
1006
+ export const datapointConfig = strictObject({
1007
+ topic: rosName.meta({
1008
+ description: 'The ROS topic this datapoint reads, as an absolute graph name. One datapoint reads **one** topic: a value assembled from two topics is not expressible here.',
1009
+ patternErrorMessage: ROS_NAME_RULE,
1010
+ examples: ['/battery'],
1011
+ }),
1012
+ type: rosTypeName.meta({
1013
+ description: 'The message type carried by `topic`, spelled the way ROS 2 spells it, with the `msg` segment in the middle — `sensor_msgs/msg/BatteryState`, never `sensor_msgs/BatteryState`. It is declared here rather than discovered, so a configuration can be written for a robot that has never been connected; the cloud checks it against the robot\'s own message definitions only once one is there.',
1014
+ patternErrorMessage: ROS_TYPE_NAME_RULE,
1015
+ examples: ['sensor_msgs/msg/BatteryState'],
1016
+ }),
1017
+ field: fieldPath.meta({
1018
+ description: 'A dotted path into the message naming the single value this datapoint carries, each segment indexing at most one array level — `ranges[0]`, never `ranges[0][1]`, because ROS 2 has no nested arrays. Without it the datapoint is the whole message, and `numeric`, `chart` and `alerts` are then refused.',
1019
+ patternErrorMessage: FIELD_PATH_RULE,
1020
+ examples: ['voltage', 'pose.position.x', 'ranges[0]'],
1021
+ }).optional(),
1022
+ rate_throttle_hz: rateThrottleHz.meta({
1023
+ description: 'A ceiling on how often this datapoint is sent, in hertz. Omitted or `0` means no throttling. It is **a ceiling, not a clock**: a slow topic stays slow, a value is never repeated to manufacture a rate, and within a window the newest value wins. The bridge enforces it, so the robot\'s bandwidth is genuinely saved.',
1024
+ examples: [2, 0.5],
1025
+ }).optional(),
1026
+ description: serviceDescription.meta({
1027
+ description: 'Prose about what this value is, for whoever meets it in the console later. It changes nothing the robot does, so a publish that touches only it pushes no configuration at all — but it is carried verbatim into `robot_describe`, where a model that has never seen this robot reads it. The datapoint is offered whenever the role grants it; without one it is offered with `description: null` and the model has less to go on, as for actions, services, publishers and cameras. Omission is the only way to say nothing; an empty string is refused, here and on all five.',
1028
+ /**
1029
+ * The sentence both datapoint snippets already place here, verbatim. Its
1030
+ * four siblings — an action's, a service's, a publisher's, a camera's —
1031
+ * each carry the sentence from their own snippet body, so this position
1032
+ * was the one description in the format offering nothing; a second wording
1033
+ * invented here would have been the drift instead.
1034
+ */
1035
+ examples: ['What this value is, for whoever meets it in the console.'],
1036
+ }),
1037
+ numeric: datapointNumeric.meta({
1038
+ description: 'Arithmetic and formatting for a numeric value. `scale` and `offset` are applied **on the robot**, before sending, which is why REST, realtime and history all carry identical numbers. `unit` and `decimals` change nothing the robot does, so a publish that touches only those pushes no configuration.',
1039
+ /**
1040
+ * The quotes inside `unit` are inserted text, not decoration, for the
1041
+ * reason spelled out on the `datapoints` snippet below: a body string is
1042
+ * written to the buffer verbatim and a bare `%` is a YAML directive
1043
+ * indicator, so `unit: %` is a syntax error where `unit: "%"` parses.
1044
+ *
1045
+ * **`offset` is not in the body, and that is a choice rather than an
1046
+ * oversight.** All four fields carry `examples`, but they were authored
1047
+ * per field and from two different conversions: `scale: 100` with
1048
+ * `unit: '%'` is a 0..1 fraction shown as a percentage, while
1049
+ * `offset: -273.15` is kelvin as celsius. A body holding both would
1050
+ * insert arithmetic that means nothing and that a developer has to
1051
+ * unpick before it means anything. The rule this file follows is that a
1052
+ * skeleton carries what a developer opening the block almost certainly
1053
+ * wants, at the node's own example values; the remaining keys arrive by
1054
+ * ordinary key completion, which works here and never stopped working —
1055
+ * the position that was silent is the *value* after `numeric:`.
1056
+ */
1057
+ defaultSnippets: [{
1058
+ label: 'a unit, and the arithmetic that produces it',
1059
+ description: 'A 0..1 fraction sent as a percentage to one decimal. `scale` is applied on the robot before sending, so history stores the converted value and a later correction cannot reach what is already stored.',
1060
+ body: { scale: 100, unit: '"%"', decimals: 1 },
1061
+ }],
1062
+ }).optional(),
1063
+ retention: datapointRetention.meta({
1064
+ description: 'What outlives the moment: whether this value is written to the time series, how often, and how many points the robot buffers while the bridge is away. Absent means no history at all — the value is live only.',
1065
+ /**
1066
+ * `enabled: true` is the only value that makes opening this block mean
1067
+ * anything — absent already means off, so a skeleton inserting `false`
1068
+ * would be a block that does nothing. It is a boolean and carries no
1069
+ * `examples`; the direction comes from the schema comment above, which
1070
+ * says why absent-means-off is the deliberate one.
1071
+ *
1072
+ * `interval_seconds: 300` restates the format's own default, on purpose:
1073
+ * stored points are what a customer is billed for, so this is the direct
1074
+ * lever on what a robot costs, and a developer who never sees the field
1075
+ * never tunes it.
1076
+ *
1077
+ * **This body carries `max_buffer_values` and the composite `datapoints`
1078
+ * snippet's `retention:` does not, deliberately.** The two answer
1079
+ * different questions and the difference is the answer to each: the
1080
+ * composite says *what a datapoint looks like*, where retention is one
1081
+ * of three sub-blocks and the robot-side buffer is a tuning detail that
1082
+ * would bury the shape it is there to show; this node is reached only by
1083
+ * a developer who has written `retention:` and asked what goes in it, and
1084
+ * for that question the buffer is a third of the answer. Neither is the
1085
+ * corrected version of the other.
1086
+ */
1087
+ defaultSnippets: [{
1088
+ label: 'history, on, with its interval and buffer',
1089
+ description: 'Writes this value to the time series every 300 seconds and holds 5000 points on the robot while the bridge is away. Stored points are billed, so both numbers are worth choosing rather than inheriting.',
1090
+ body: { enabled: true, interval_seconds: 300, max_buffer_values: 5000 },
1091
+ }],
1092
+ }).optional(),
1093
+ chart: datapointChart.meta({
1094
+ description: 'How the console draws this value over time: axis bounds, whether the line interpolates or steps, and the window a chart opens on. **Display only** — it changes no stored value, no alert and nothing the robot does, so a publish that touches only it pushes no configuration.',
1095
+ /**
1096
+ * `style` is a **choice**, not a literal, for the reason the camera
1097
+ * source's `type` is one: the format offers a closed pair, the right
1098
+ * answer depends on what the datapoint is, and the snippet cannot know.
1099
+ * Its own description says the two are not a matter of taste — `line`
1100
+ * claims the value moved evenly between two samples, `step` holds and
1101
+ * jumps, and `step` is the only honest drawing for a mode, a switch or a
1102
+ * counter. A snippet that picked `line` would draw values that never
1103
+ * existed, and nothing would object: both are valid, no diagnostic
1104
+ * fires, and the chart looks plausible.
1105
+ *
1106
+ * `Choice.toString()` is the first option, so a developer who tabs past
1107
+ * this gets `line`, which is right for the continuous values most charts
1108
+ * carry; one who opens the picker sees that `step` exists at all.
1109
+ *
1110
+ * The composite snippet on `datapoints` writes `style: 'line'` as a
1111
+ * literal and stays that way — its body is a battery percentage, where
1112
+ * `line` is not a guess.
1113
+ *
1114
+ * **`default_window_minutes` is left out, on the same rule that leaves
1115
+ * `offset` out of `numeric` above**, and it is said here so that the two
1116
+ * omissions read alike: it has its own `examples` (`1440`) and this
1117
+ * node's description names it, but it is the one field of the four that
1118
+ * decides nothing about the drawing — absent means 60, a viewer may look
1119
+ * further whatever it says, and nothing about what is stored follows from
1120
+ * it. Key completion offers it inside the block the moment anyone wants
1121
+ * it; the position that was silent is the *value* after `chart:`.
1122
+ */
1123
+ defaultSnippets: [{
1124
+ label: 'axis bounds, and how two samples are joined',
1125
+ description: 'A fixed 0..100 axis rather than one that scales to the data, and a choice between interpolating and stepping between samples — which is not a matter of taste.',
1126
+ body: { y_min: 0, y_max: 100, style: '${1|line,step|}' },
1127
+ }],
1128
+ }).optional(),
1129
+ alerts: slugKeyed(datapointAlert).meta({
1130
+ description: 'Alerts watching this value, keyed by slug; each moves between `ok` and `firing` and writes an org event on every transition. No mail is sent. **The key is the identity**, so renaming an alert is a delete plus a create: its runtime state is lost, and an alert that is still true fires again.',
1131
+ /**
1132
+ * The body is `ALERT_SNIPPET` under its slug key — the same skeleton the
1133
+ * entry position offers, and the reasons behind every value in it are
1134
+ * written there. **The key is in the wrapper**, and it is the alert's
1135
+ * identity: renaming it is a delete plus a create.
1136
+ */
1137
+ defaultSnippets: [underSlug('${1:battery_low}', ALERT_SNIPPET)],
1138
+ }).optional(),
1139
+ })
1140
+ .superRefine((d, ctx) => {
1141
+ if (d.field !== undefined)
1142
+ return;
1143
+ for (const group of ['numeric', 'chart', 'alerts']) {
1144
+ if (d[group] !== undefined)
1145
+ ctx.addIssue({
1146
+ code: 'custom',
1147
+ path: [group],
1148
+ message: `${group} needs a single field; without 'field' the value is the whole message`,
1149
+ params: { code: 'requires_single_field' },
1150
+ });
1151
+ }
1152
+ })
1153
+ /**
1154
+ * The value position of one entry — `battery: ▮` under `datapoints:`, where a
1155
+ * developer adding a **second** datapoint by hand stands. Both skeletons the
1156
+ * section offers, in the same order, so the choice between a plain value and
1157
+ * a fully-equipped one is the same choice at both positions.
1158
+ */
1159
+ .meta({ defaultSnippets: [DATAPOINT_SNIPPET, NUMERIC_DATAPOINT_SNIPPET] });
1160
+ /**
1161
+ * Whether a template holds an explicit `null` anywhere inside it.
1162
+ *
1163
+ * At **any depth**, and the depth is the whole point. Every other field in
1164
+ * this file is `.optional()` rather than `.nullable()`, so zod refuses `null`
1165
+ * at each of them for free. A message body is the one exception — it is
1166
+ * `z.unknown()`, because a template can be any shape a ROS message can — so
1167
+ * nothing below the top of it is checked by the type at all.
1168
+ *
1169
+ * That gap was measured and missed once already: a top-level `message: null`
1170
+ * was refused while `message: { linear: { x: null } }` parsed clean, and a
1171
+ * check written to catch exactly this was deleted on the strength of six test
1172
+ * cases, none of which reached inside a body.
1173
+ *
1174
+ * Walked with an explicit stack and a seen-set, not recursion: a YAML anchor
1175
+ * can make a template both very deep and genuinely cyclic, and a developer can
1176
+ * legitimately write one.
1177
+ */
1178
+ function holdsExplicitNull(node) {
1179
+ const stack = [node];
1180
+ const seen = new WeakSet();
1181
+ while (stack.length > 0) {
1182
+ const current = stack.pop();
1183
+ if (current === null)
1184
+ return true;
1185
+ if (typeof current !== 'object')
1186
+ continue;
1187
+ if (seen.has(current))
1188
+ continue;
1189
+ seen.add(current);
1190
+ stack.push(...(Array.isArray(current) ? current : Object.values(current)));
1191
+ }
1192
+ return false;
1193
+ }
1194
+ /**
1195
+ * A message template: the goal, request or published message, written out in
1196
+ * full. Literals are fixed; `${name}` is a hole a caller fills.
1197
+ *
1198
+ * The shape cannot be narrower than `unknown` here — it is the shape of an
1199
+ * arbitrary ROS message, which only the robot's own type definition knows.
1200
+ * What CAN be checked here is the placeholder grammar; everything else is
1201
+ * checked in the cloud against the introspected type.
1202
+ *
1203
+ * **`null` is refused, at every depth of this position.** Omission is the
1204
+ * only spelling of "not set" in this format, and a bare `z.unknown()` made
1205
+ * every message position the one place that also accepted the second
1206
+ * spelling: `publishers.p.message: null`, `failsafe.message: null`,
1207
+ * `actions.a.message: null` and `messages: {stop: null}` all parsed. Every
1208
+ * other field gets this from its own type refusing `null`; this one has no
1209
+ * type to get it from, so it says it here.
1210
+ *
1211
+ * The refusal is a refinement and therefore **invisible in the JSON Schema
1212
+ * artifact**, which publishes this position as `{}`. The artifact says what
1213
+ * the shape is, not what the parser refuses; a consumer that validates
1214
+ * against the artifact instead of against this schema does not get it.
1215
+ */
1216
+ export const messageTemplate = z.unknown().refine((v) => !holdsExplicitNull(v), {
1217
+ message: 'null is not a value; omit the key instead',
1218
+ params: { code: 'explicit_null' },
1219
+ }).meta({
1220
+ description: 'The message as it will be sent, written out in full: literals are fixed, `${name}` is a hole a caller fills, and a field written `0.0` is one no client can change. Directly after `message:` a `${name}` standing alone names a shared message instead; anywhere inside a body it is a parameter. `null` is refused **at every depth** — omitting a key is the only spelling of "not set".',
1221
+ });
1222
+ /** `${name}` and nothing else. A bare word is always a literal. */
1223
+ export const PLACEHOLDER_RE = /^\$\{([a-z][a-z0-9]*(?:_[a-z0-9]+)*)\}$/;
1224
+ export const messageRef = z.string().regex(PLACEHOLDER_RE).meta({
1225
+ description: 'A reference to a shared message: `${name}` and nothing else, which is what separates a reference from a literal — a bare word stays a literal even when it happens to match a declared name. Whether that name is declared, and whether it points at a body holding a second reference, are questions about the whole document and are answered in the cloud.',
1226
+ });
1227
+ /**
1228
+ * What may stand at a `message:` position: a shared message by name
1229
+ * (`${name}`), or an inline template. Position decides which — directly after
1230
+ * `message:` a `${name}` resolves to a shared message, inside a body it
1231
+ * resolves to a parameter.
1232
+ *
1233
+ * **This is `messageTemplate`, not a union with `messageRef`, and that is a
1234
+ * correction rather than a simplification.** It was written as
1235
+ * `z.union([messageRef, messageTemplate])`, whose second member accepts
1236
+ * everything the first does: the union could never refuse, never narrowed
1237
+ * anything (`string | unknown` is `unknown`), and published as
1238
+ * `anyOf: [{pattern: …}, {}]` — an artifact that claims a distinction no
1239
+ * validator makes. The distinction is real but it is not a shape distinction:
1240
+ * a `${name}` here means a reference and elsewhere means a parameter, and
1241
+ * only the cloud can say whether that name is a defined message
1242
+ * (`unknown_message`) or a nested one (`nested_message_reference`).
1243
+ *
1244
+ * `messageRef` stays exported as the predicate that decides it. It is what a
1245
+ * consumer applies to a body to ask "is this a reference?"; it is not what
1246
+ * validates one.
1247
+ */
1248
+ export const messageBody = messageTemplate;
1249
+ /**
1250
+ * Every placeholder name in a template, at any depth.
1251
+ *
1252
+ * **An explicit stack, not recursion, and the reason is `safeParse`'s
1253
+ * contract.** This runs inside `publisherConfig`'s failsafe refinement, so a
1254
+ * `RangeError: Maximum call stack size exceeded` did not stay here: it
1255
+ * propagated out of `safeParse`, which is specified to return a result and
1256
+ * not to throw. Measured on the recursive version — fine at 8 000 levels of
1257
+ * nesting, throwing at 20 000 — and a flow-style YAML one-liner reaches that
1258
+ * in about 120 KB of input. A draft PUT would have answered 500 where it
1259
+ * meant 400.
1260
+ *
1261
+ * `seen` is not an optimisation. YAML anchors can express a cycle
1262
+ * (`&a { b: *a }`), and the parser resolves an alias to the same object, so
1263
+ * without it the loop that fixed the overflow would hang instead — the
1264
+ * failure mode a stack trades for, made worse by being silent.
1265
+ *
1266
+ * The array branch is explicit, not necessary: `Object.values()` on an array
1267
+ * yields the same elements. It is here so the walk reads as covering both
1268
+ * shapes; a reader does not have to know that property of `Object.values`.
1269
+ */
1270
+ export function placeholderNames(node, found = new Set()) {
1271
+ const stack = [node];
1272
+ const seen = new WeakSet();
1273
+ while (stack.length > 0) {
1274
+ const current = stack.pop();
1275
+ if (typeof current === 'string') {
1276
+ const m = PLACEHOLDER_RE.exec(current);
1277
+ if (m)
1278
+ found.add(m[1]);
1279
+ continue;
1280
+ }
1281
+ if (!current || typeof current !== 'object')
1282
+ continue;
1283
+ if (seen.has(current))
1284
+ continue;
1285
+ seen.add(current);
1286
+ if (Array.isArray(current)) {
1287
+ for (const item of current)
1288
+ stack.push(item);
1289
+ }
1290
+ else {
1291
+ for (const value of Object.values(current))
1292
+ stack.push(value);
1293
+ }
1294
+ }
1295
+ return found;
1296
+ }
1297
+ /**
1298
+ * One shared message body, authored once for the two positions it is offered
1299
+ * from: `messages:` (under `underSlug`) and the value of one entry below it.
1300
+ *
1301
+ * `param()` and not a bare `${speed}` — the backslash is why the hole survives
1302
+ * the insert; see `param` for the measurement.
1303
+ */
1304
+ const SHARED_MESSAGE_SNIPPET = {
1305
+ label: 'a shared message',
1306
+ description: 'One reusable body, with one parameter hole in it.',
1307
+ body: {
1308
+ linear: { x: param('speed') },
1309
+ angular: { z: 0 },
1310
+ },
1311
+ };
1312
+ /**
1313
+ * The value position of one shared message — `drive: ▮` under `messages:`.
1314
+ *
1315
+ * **This is a clone of `messageTemplate` and not `messageTemplate` itself,
1316
+ * deliberately.** `messageBody` *is* `messageTemplate`, the same instance, so a
1317
+ * `defaultSnippets` written onto it would also reach `message:` under an
1318
+ * action, a service and a publisher — where the body below is a zero twist
1319
+ * offered as the skeleton for a nav2 goal, which is worse than the silence it
1320
+ * replaced. Those three positions take arbitrary content shaped by the entry's
1321
+ * own ROS type, and nothing here knows it. `.meta()` clones rather than
1322
+ * mutating, so `messageTemplate` is untouched, and the template's own
1323
+ * `description` reaches this node **without being restated** — a second copy of
1324
+ * that paragraph would be a second thing to keep true.
1325
+ *
1326
+ * **How the description gets here is zod behaviour, not something written
1327
+ * below.** Measured against zod 4.4.3: `.meta()` on an already-registered
1328
+ * schema merges rather than replaces, and the clone resolves the parent's entry
1329
+ * *lazily* — a clone taken before the parent was registered at all still sees
1330
+ * the parent's description afterwards. So no spread is needed and there is no
1331
+ * evaluation-order hazard. This was first written as
1332
+ * `.meta({ ...messageTemplate.meta(), … })`; dropping the spread was measured
1333
+ * to change nothing in the export, and two mechanisms for one description is
1334
+ * the shape this file removes rather than adds.
1335
+ *
1336
+ * It is undocumented behaviour all the same, so `config-snippets.test.ts`
1337
+ * asserts this node still carries a description and that it is the same string
1338
+ * as the `message:` position — if a zod release stops merging, that is a red
1339
+ * test rather than a hover that silently went blank.
1340
+ */
1341
+ const sharedMessageBody = messageTemplate.meta({ defaultSnippets: [SHARED_MESSAGE_SNIPPET] });
1342
+ /**
1343
+ * Reusable message bodies, keyed by name. A shared message may hold
1344
+ * placeholders; whoever inserts it declares the parameters. It may NOT
1345
+ * insert another — that excludes cycles and lets every check look at exactly
1346
+ * one body instead of walking a reference tree.
1347
+ */
1348
+ export const messageMap = slugKeyed(sharedMessageBody)
1349
+ .refine((m) => Object.keys(m).length <= 200, { message: 'at most 200 shared messages' })
1350
+ .meta({
1351
+ description: 'Reusable message bodies, keyed by name. A body is inserted by writing `${name}` directly after `message:`, may hold placeholders of its own, and **may not insert another** — which rules out cycles and lets every check look at exactly one body.',
1352
+ });
1353
+ /**
1354
+ * One action, authored once for the two positions it is offered from:
1355
+ * `actions:` (under `underSlug`) and the value of one entry below it. The three
1356
+ * required fields at the node's own `examples`; `message` and `parameters`
1357
+ * depend on the action type and are left to key completion.
1358
+ */
1359
+ const ACTION_SNIPPET = {
1360
+ label: 'an action',
1361
+ description: 'One thing the robot does on request, reported as a job with progress.',
1362
+ body: {
1363
+ ros_name: '${2:/navigate_to_pose}',
1364
+ type: '${3:nav2_msgs/action/NavigateToPose}',
1365
+ description: '${4:Drives to a target pose on the map.}',
1366
+ },
1367
+ };
1368
+ /**
1369
+ * An action the robot can be asked to perform (spec §4.2, §11.3). At most one
1370
+ * job runs per action slug; a second call is refused `busy`, and every
1371
+ * observer of the slug watches the same job.
1372
+ */
1373
+ export const actionConfig = strictObject({
1374
+ ros_name: rosName.meta({
1375
+ description: 'The action server on the robot, as an absolute graph name — this is what the bridge sends the goal to. Clients never see it: they address this entry by its slug, so a server can be renamed on the robot without a single app changing.',
1376
+ patternErrorMessage: ROS_NAME_RULE,
1377
+ examples: ['/navigate_to_pose'],
1378
+ }),
1379
+ type: rosTypeName.meta({
1380
+ description: 'The action type `ros_name` implements, with the `action` segment in the middle — `nav2_msgs/action/NavigateToPose`, never `nav2_msgs/NavigateToPose`. Declared rather than introspected, so an action can be configured for a robot that has never connected; the cloud checks it against the robot\'s own definitions only once one is there.',
1381
+ patternErrorMessage: ROS_TYPE_NAME_RULE,
1382
+ examples: ['nav2_msgs/action/NavigateToPose'],
1383
+ }),
1384
+ message: messageBody.optional(),
1385
+ parameters: parameterMap.optional(),
1386
+ description: serviceDescription.meta({
1387
+ description: 'What this action does, in the developer\'s own words — documentation for the console and for MCP clients, which is all it is: the robot does nothing with it. It is carried verbatim into `robot_describe` and read by a model that has never seen this robot. The action is offered whenever the role grants it; without one it is offered with `description: null`, and the model has nothing but the slug.',
1388
+ examples: ['Drives to a target pose on the map.'],
1389
+ }),
1390
+ }).meta({
1391
+ /** The value position of one entry — `navigate: ▮` under `actions:`. */
1392
+ defaultSnippets: [ACTION_SNIPPET],
1393
+ });
1394
+ /**
1395
+ * One service, authored once for the two positions it is offered from:
1396
+ * `services:` (under `underSlug`) and the value of one entry below it. The
1397
+ * example is `Trigger`, whose request has no fields — so `message` and
1398
+ * `parameters` are genuinely absent rather than merely left out.
1399
+ */
1400
+ const SERVICE_SNIPPET = {
1401
+ label: 'a service',
1402
+ description: 'One request, one reply, no progress in between.',
1403
+ body: {
1404
+ ros_name: '${2:/reset_odometry}',
1405
+ type: '${3:std_srvs/srv/Trigger}',
1406
+ description: '${4:Resets odometry to the origin.}',
1407
+ },
1408
+ };
1409
+ /** A ROS service call with validated parameters (spec §4.2). */
1410
+ export const serviceConfig = strictObject({
1411
+ ros_name: rosName.meta({
1412
+ description: 'The ROS service the robot answers on, as an absolute graph name. The call is one request and one reply with no progress in between, so whatever this service does has to finish inside that reply; anything long-running belongs in `actions`.',
1413
+ patternErrorMessage: ROS_NAME_RULE,
1414
+ examples: ['/reset_odometry'],
1415
+ }),
1416
+ type: rosTypeName.meta({
1417
+ description: 'The service type `ros_name` implements, with the `srv` segment in the middle — `std_srvs/srv/Trigger`. A type whose request has no fields, like `Trigger`, needs neither `message` nor `parameters`: there is nothing to fill.',
1418
+ patternErrorMessage: ROS_TYPE_NAME_RULE,
1419
+ examples: ['std_srvs/srv/Trigger'],
1420
+ }),
1421
+ message: messageBody.optional(),
1422
+ parameters: parameterMap.optional(),
1423
+ description: serviceDescription.meta({
1424
+ description: 'What this service does, in the developer\'s own words. The robot does nothing with it — the readers are the console and MCP clients, and without one the service is still offered, with `description: null`, exactly as for an action. It sits on the configuration rather than on the app, so one wording is true for every app that reaches this robot.',
1425
+ examples: ['Resets odometry to the origin.'],
1426
+ }),
1427
+ }).meta({
1428
+ /** The value position of one entry — `reset_odometry: ▮` under `services:`. */
1429
+ defaultSnippets: [SERVICE_SNIPPET],
1430
+ });
1431
+ /**
1432
+ * One publisher, authored once for the two positions it is offered from:
1433
+ * `publishers:` (under `underSlug`) and the value of one entry below it.
1434
+ *
1435
+ * This is the one skeleton in the file that carries every part of the format at
1436
+ * once — a fixed message with two holes, the parameters that declare them, and
1437
+ * the failsafe — because a publisher with any of them missing is a publisher
1438
+ * the format refuses. `failsafe` is required and its message may hold no
1439
+ * placeholder: it is sent with no caller left to fill one.
1440
+ */
1441
+ const PUBLISHER_SNIPPET = {
1442
+ label: 'a publisher, with its parameters and its failsafe',
1443
+ description: 'A topic clients may send to: what is fixed, what a caller fills, and what the bridge sends by itself once the caller falls silent.',
1444
+ body: {
1445
+ topic: '${2:/cmd_vel}',
1446
+ type: '${3:geometry_msgs/msg/Twist}',
1447
+ message: {
1448
+ linear: { x: param('speed') },
1449
+ angular: { z: param('turn') },
1450
+ },
1451
+ parameters: {
1452
+ speed: { type: 'float64', min_value: -0.5, max_value: 0.5, default: 0 },
1453
+ turn: { type: 'float64', min_value: -0.5, max_value: 0.5, default: 0 },
1454
+ },
1455
+ failsafe: {
1456
+ timeout_ms: 500,
1457
+ message: {
1458
+ linear: { x: 0 },
1459
+ angular: { z: 0 },
1460
+ },
1461
+ },
1462
+ quiet_timeout_ms: 2000,
1463
+ description: '${4:Velocity command. If sending stops, the robot stops.}',
1464
+ },
1465
+ };
1466
+ /**
1467
+ * A topic clients may publish to.
1468
+ *
1469
+ * `failsafe` groups the deadline with the message it triggers, because the
1470
+ * deadline exists for nothing else. The message must hold no placeholder:
1471
+ * the bridge sends it with no caller present, so there would be nobody to
1472
+ * fill one.
1473
+ *
1474
+ * **What that check can and cannot see.** It refuses a placeholder written
1475
+ * into an inline failsafe body. It does not refuse
1476
+ * `failsafe: { message: '${anything}' }` — a string at a `message:` position
1477
+ * is a *reference to a shared message*, and whether that message holds a
1478
+ * placeholder is a question about another section of the document, which a
1479
+ * schema over one publisher cannot answer. So `failsafe_has_parameters` is
1480
+ * half here and half in the cloud, on purpose and by position rather than by
1481
+ * accident: the inline half is decidable here, the referenced half is one of
1482
+ * the name-resolution codes the file header assigns to the cloud.
1483
+ *
1484
+ * `quiet_timeout_ms` is unrelated — how long a publisher must be silent
1485
+ * before a *different* user may send.
1486
+ */
1487
+ export const publisherConfig = strictObject({
1488
+ topic: rosName.meta({
1489
+ description: 'The ROS topic the message is published onto, as an absolute graph name. **No client ever names a topic**: a caller addresses this entry by its slug, so the topics an app can write to are exactly the ones written in this file.',
1490
+ patternErrorMessage: ROS_NAME_RULE,
1491
+ examples: ['/cmd_vel'],
1492
+ }),
1493
+ type: rosTypeName.meta({
1494
+ description: 'The message type of `topic`, spelled the way ROS 2 spells it, with the `msg` segment. It fixes the shape that `message` and `failsafe.message` must both fill, which is why one publisher carries one type and a second type needs a second publisher.',
1495
+ patternErrorMessage: ROS_TYPE_NAME_RULE,
1496
+ examples: ['geometry_msgs/msg/Twist'],
1497
+ }),
1498
+ message: messageBody,
1499
+ parameters: parameterMap.optional(),
1500
+ failsafe: strictObject({
1501
+ timeout_ms: z.number().int().positive().max(60_000).meta({
1502
+ description: 'How long the bridge waits for the client\'s next send before sending the failsafe message itself, in milliseconds. The deadline runs **on the robot**, so it still fires when the link to the cloud is what failed — which is the case it exists for.',
1503
+ examples: [500, 1000],
1504
+ }),
1505
+ message: messageBody.meta({
1506
+ description: 'What the bridge sends once `timeout_ms` runs out — for a drive command, a zero twist. It must be safe in **every** state, because it is sent precisely when nobody is watching any more, and it may hold no placeholder: there is no caller left to fill one.',
1507
+ }),
1508
+ })
1509
+ /**
1510
+ * The string case is the exemption, not an oversight — see the paragraph
1511
+ * above — so it is tested first, where it reads as one.
1512
+ */
1513
+ .refine((f) => typeof f.message === 'string' || placeholderNames(f.message).size === 0, {
1514
+ message: 'the failsafe message must contain no placeholder: it is sent with no caller to fill one',
1515
+ path: ['message'],
1516
+ params: { code: 'failsafe_has_parameters' },
1517
+ })
1518
+ .meta({
1519
+ description: 'What the bridge sends **by itself** once a client stops sending, and how long it waits first. This is the format\'s safety story in one field: a client that crashes, loses its connection or whose operator closes the window does not leave a robot driving. The message may hold no placeholder, inline or through a shared message — there is nobody left to fill one.',
1520
+ /**
1521
+ * Both fields are required, so the body carries both: a `failsafe:` with
1522
+ * only one of them is a publisher the format refuses, and this is the
1523
+ * field where a document that does not publish is the least useful thing
1524
+ * to hand somebody.
1525
+ *
1526
+ * The zero twist is the message this field's own description names, and
1527
+ * the same body the composite `publishers` snippet inserts. Neither
1528
+ * knows the publisher's ROS type — nothing at this position does — so
1529
+ * the snippet offers the format's canonical safe message rather than
1530
+ * guessing a shape. Every value in it is a literal: a placeholder here
1531
+ * is refused outright (`failsafe_has_parameters`), because the message
1532
+ * is sent with no caller left to fill one.
1533
+ */
1534
+ defaultSnippets: [{
1535
+ label: 'a deadline, and the message it sends',
1536
+ description: 'Half a second of silence and then a zero twist. The deadline runs on the robot, so it still fires when the link to the cloud is what failed — which is the case it exists for.',
1537
+ body: {
1538
+ timeout_ms: 500,
1539
+ message: {
1540
+ linear: { x: 0 },
1541
+ angular: { z: 0 },
1542
+ },
1543
+ },
1544
+ }],
1545
+ }),
1546
+ quiet_timeout_ms: z.number().int().nonnegative().max(600_000).meta({
1547
+ description: 'How long this publisher must stay silent before a **different** user may send to it. Whoever sends holds it implicitly exclusive, with no session and no lock, so this one number is the whole handover policy: too short and two operators fight over one robot, too long and a crashed client blocks it for everyone.',
1548
+ examples: [2000],
1549
+ }),
1550
+ description: serviceDescription.meta({
1551
+ description: 'What sending to this publisher does, in the developer\'s own words. It is documentation for the console and for MCP clients — the robot does nothing with it — and as for actions and services, the publisher is offered whether or not one is written, with `description: null` when it is not. A caller sends here repeatedly and continuously rather than once, which is why this kind alone carries `failsafe` and `quiet_timeout_ms`.',
1552
+ examples: ['Velocity command. If sending stops, the robot stops.'],
1553
+ }),
1554
+ }).meta({
1555
+ /** The value position of one entry — `drive: ▮` under `publishers:`. */
1556
+ defaultSnippets: [PUBLISHER_SNIPPET],
1557
+ });
1558
+ /**
1559
+ * Camera credentials, in the document. There is no separate store any more.
1560
+ *
1561
+ * This was decided against a recorded objection, and the objection stands: a
1562
+ * password here is in every published version, and those are immutable. It
1563
+ * cannot be removed from history and cannot be rotated without republishing.
1564
+ * The bound on that decision is elsewhere and load-bearing — the publish
1565
+ * audit event and the org event stream must not carry the document body.
1566
+ */
1567
+ export const cameraCredentials = strictObject({
1568
+ username: z.string().min(1).max(128).optional().meta({
1569
+ description: 'The account name the camera expects. For MJPEG the bridge sends a real HTTP `Authorization: Basic` header and leaves the URL untouched. RTSP offers no such channel through ffmpeg, so there the name goes inside the connect URL instead — built fresh for that one call and never written back into the stored document.',
1570
+ examples: ['ops'],
1571
+ }),
1572
+ password: z.string().min(1).max(128).optional().meta({
1573
+ description: 'The password for `username`. **There is no secret store behind this**: the value written here is the value stored, so treat it as readable by everyone who may read this robot\'s configuration, now and in its history.',
1574
+ }),
1575
+ })
1576
+ .meta({
1577
+ description: 'Username and password for the stream, standing **in clear text in the document**. A published version is immutable, so a password here cannot be removed from history or rotated without republishing — which is why the publish audit event carries only the version number and never the document body. Userinfo in the `url` works too; an explicit block here wins over it.',
1578
+ defaultSnippets: [{
1579
+ label: 'username and password',
1580
+ description: 'Both fields, in clear text — which is what this block is. The password default is deliberately not a password: `CHANGE-ME` is stored like any other value, but it is **visible** rather than plausible, so a reviewer reading the diff sees it and the camera rejects it at connect time — where a default that looked like a password would simply be published and kept.',
1581
+ /**
1582
+ * `CHANGE-ME`, and not a plausible-looking password, because of what the
1583
+ * comment above this schema records: a published version is immutable,
1584
+ * so a password written here cannot be removed from history or rotated
1585
+ * without republishing. This snippet is the one thing in the file that
1586
+ * could manufacture such a version by itself — a developer who tabs past
1587
+ * the placeholder publishes whatever the default was.
1588
+ *
1589
+ * What `CHANGE-ME` buys is **visibility, not a refusal**. It is stored
1590
+ * exactly like any other value; nothing at publish time objects. What it
1591
+ * does is fail at the camera, at connect time, and read wrong to anyone
1592
+ * looking at the diff — where a plausible default is published and kept.
1593
+ *
1594
+ * The two alternatives were both worse. A plausible default (`secret`)
1595
+ * reads in a diff like a value somebody chose, so nobody looks twice. A
1596
+ * bare `$2` inserts the empty string, which `min(1)` refuses — that is
1597
+ * loud, but it makes this the only snippet in the format that knowingly
1598
+ * inserts an invalid document, and the guard that says none of them do
1599
+ * would need an exception carved for it. A guard with an exception is not
1600
+ * a guard. So the default stays valid and stays obviously wrong: no
1601
+ * camera accepts it, and no reviewer reads past it.
1602
+ *
1603
+ * Both fields are `.optional()` — a bare `{}` parses — so nothing forces
1604
+ * a default here at all. It is offered because a developer who opened
1605
+ * this block wants both fields, and the snippet exists to save them the
1606
+ * typing, not to decide anything.
1607
+ */
1608
+ body: {
1609
+ username: '${1:ops}',
1610
+ password: '${2:CHANGE-ME}',
1611
+ },
1612
+ }],
1613
+ });
1614
+ /**
1615
+ * Where a camera's frames come from (spec §10 names four sources).
1616
+ *
1617
+ * A discriminated union rather than optional fields, so an impossible camera
1618
+ * is **unrepresentable** rather than merely invalid — there is no way to
1619
+ * write an RTSP camera with a ROS topic, or a V4L2 device with a URL, and
1620
+ * therefore no validation rule to forget.
1621
+ *
1622
+ * **Each branch carries its own `defaultSnippets`, rather than one list on the
1623
+ * union.** Both placements were measured and both work; this one keeps a
1624
+ * label beside the branch it names, so the two cannot drift, and it makes a
1625
+ * fifth source impossible to add without one — `config-snippets.test.ts`
1626
+ * walks the exported branches and fails on any that carries none.
1627
+ */
1628
+ /**
1629
+ * Named for the same reason as `chartStyle`: `describeValues` needs `.options`.
1630
+ */
1631
+ const rtspTransport = z.enum(['tcp', 'udp']);
1632
+ export const cameraSource = z.discriminatedUnion('kind', [
1633
+ strictObject({
1634
+ kind: z.literal('ros').meta({
1635
+ description: 'Selects the ROS image-topic source: this camera then carries `topic` and `type`, and no field of another kind.',
1636
+ }),
1637
+ topic: rosName.meta({
1638
+ description: 'The ROS image topic the bridge subscribes to, as an absolute graph name. Clients never name it — they address the camera by its slug — so the topic can be renamed on the robot without an app changing.',
1639
+ patternErrorMessage: ROS_NAME_RULE,
1640
+ examples: ['/camera/image_raw'],
1641
+ }),
1642
+ type: rosTypeName.meta({
1643
+ description: 'The message type of `topic`: `sensor_msgs/msg/Image` for raw frames, `sensor_msgs/msg/CompressedImage` for a camera that already encodes. Declared here rather than introspected, so a camera can be configured for a robot that has never connected.',
1644
+ patternErrorMessage: ROS_TYPE_NAME_RULE,
1645
+ examples: ['sensor_msgs/msg/Image'],
1646
+ }),
1647
+ }).meta({
1648
+ description: 'Frames come from an image topic the robot already publishes. It is the only source the bridge **subscribes** to rather than opens, so it needs no URL, no device and nobody to authenticate to.',
1649
+ defaultSnippets: [{
1650
+ label: 'ros — an image topic the robot already publishes',
1651
+ description: 'Subscribes to a topic that is already there; nothing is opened and there is nobody to authenticate to.',
1652
+ /**
1653
+ * `type` is a **choice**, not a literal, and that is a correction: it was
1654
+ * written out on the rule that a field the format fixes is written out,
1655
+ * and `type` is not such a field. Its own description names two values
1656
+ * and says which applies when — `Image` for raw frames,
1657
+ * `CompressedImage` for a camera that encodes itself. A snippet that
1658
+ * picks one picks wrong for half the cameras, and picks it invisibly:
1659
+ * `rosTypeName` accepts either, publish accepts either, no diagnostic
1660
+ * fires anywhere, and the bridge then subscribes with the wrong type and
1661
+ * delivers no frames. A snippet supplying a wrong answer where it could
1662
+ * have supplied a question is this project's *check that cannot fire*,
1663
+ * arriving through a hint the developer trusts.
1664
+ *
1665
+ * `kind: 'ros'` stays a literal, because the branch really does fix it.
1666
+ *
1667
+ * Measured through the actual pipeline rather than assumed, because
1668
+ * choice syntax is the one construct here that three layers must each
1669
+ * pass through unharmed: yaml-language-server's `stringifyObject` emits
1670
+ * the body verbatim, and monaco-editor 0.52.2's `SnippetParser` parses
1671
+ * `${2|a,b|}` into a placeholder carrying both options whose
1672
+ * `toString()` — the text on the buffer before anyone chooses — is the
1673
+ * first one. So a developer who tabs past this gets a document
1674
+ * byte-identical to the literal it replaced, and one who opens the
1675
+ * picker gets `CompressedImage`; both parse.
1676
+ */
1677
+ body: {
1678
+ kind: 'ros',
1679
+ topic: '${1:/camera/image_raw}',
1680
+ type: '${2|sensor_msgs/msg/Image,sensor_msgs/msg/CompressedImage|}',
1681
+ },
1682
+ }],
1683
+ }),
1684
+ strictObject({
1685
+ kind: z.literal('rtsp').meta({
1686
+ description: 'Selects the RTSP source: this camera then carries `url`, and optionally `transport` and `credentials`.',
1687
+ }),
1688
+ /**
1689
+ * Scheme-constrained deliberately. The playbook drafted `z.string().url()`
1690
+ * here and the shipped contract was `z.string().min(1).max(2048)` — nobody
1691
+ * recorded the change, and the W6 review found the consequence: the bridge
1692
+ * opens these with libraries that honour `file:` and `ftp:`, so an
1693
+ * unconstrained URL turns a configuration document into an arbitrary
1694
+ * local-file read on the robot, with the two distinct failure codes
1695
+ * doubling as a file-existence oracle. Spec §7.6 is ROS-pure exposure with
1696
+ * no shell or http features; that rule came back by omission rather than
1697
+ * by intent. The bridge re-checks this too — a robot must not become a
1698
+ * file server because a validator changed.
1699
+ */
1700
+ url: z
1701
+ .string()
1702
+ .min(1)
1703
+ .max(2048)
1704
+ .regex(/^rtsps?:\/\//i, RTSP_URL_RULE)
1705
+ .meta({
1706
+ description: 'Where the stream lives, reached from the robot rather than from the cloud. **`rtsp://` or `rtsps://` only** — the bridge opens this with a library that would equally honour `file:`, so an unconstrained URL would turn a configuration document into arbitrary file access on the robot. The bridge re-checks the scheme itself, so a validator that changed could not make a robot serve files.',
1707
+ patternErrorMessage: RTSP_URL_RULE,
1708
+ examples: ['rtsp://cam-1.plant.local/stream1'],
1709
+ }),
1710
+ /** TCP by default: UDP loses frames on a congested link, silently. */
1711
+ transport: rtspTransport.optional().meta({
1712
+ description: 'How the RTSP payload is carried. Omitted means `tcp`: `udp` loses frames on a congested link and loses them silently, so the result looks like a failing camera rather than like a choice made here.',
1713
+ enumDescriptions: describeValues(rtspTransport.options, {
1714
+ tcp: 'The frames are interleaved into the RTSP connection itself, which is what a congested or lossy link needs — nothing is dropped on the way. This is what an omitted `transport` means.',
1715
+ udp: 'The frames travel in their own UDP stream: lower latency on a quiet network, and silent frame loss on any other.',
1716
+ }),
1717
+ }),
1718
+ credentials: cameraCredentials.optional(),
1719
+ }).meta({
1720
+ description: 'Frames come from an RTSP stream the robot itself can reach — a network camera on its own LAN. The bridge opens the connection; the cloud never does, and never needs a route to the camera.',
1721
+ defaultSnippets: [{
1722
+ label: 'rtsp — a network camera the robot itself can reach',
1723
+ description: 'A stream the bridge opens over RTSP. The scheme is written out because the format constrains it; the host and the path are what vary.',
1724
+ body: {
1725
+ kind: 'rtsp',
1726
+ url: 'rtsp://${1:cam-1.plant.local}/${2:stream1}',
1727
+ },
1728
+ }],
1729
+ }),
1730
+ strictObject({
1731
+ kind: z.literal('mjpeg').meta({
1732
+ description: 'Selects the MJPEG-over-HTTP source: this camera then carries `url`, and optionally `credentials`.',
1733
+ }),
1734
+ /** `http:`/`https:` only — see the `rtsp` variant above for why. */
1735
+ url: z
1736
+ .string()
1737
+ .min(1)
1738
+ .max(2048)
1739
+ .regex(/^https?:\/\//i, MJPEG_URL_RULE)
1740
+ .meta({
1741
+ description: 'Where the stream lives. **`http://` or `https://` only** — as for the `rtsp` URL, the bridge opens it with a library that would also serve `file:`. Plain `http://` is permitted because these cameras usually sit on the robot\'s own network, but Basic credentials on such a URL then travel in the clear.',
1742
+ patternErrorMessage: MJPEG_URL_RULE,
1743
+ /**
1744
+ * The host and the path this branch's own snippet body inserts, and the
1745
+ * URL its rule sentence names — one answer to "what goes here?", not a
1746
+ * third. The sibling `rtsp` url had an `examples` from the first day and
1747
+ * this position was the format's only silent URL (§1.1).
1748
+ */
1749
+ examples: ['http://cam-1.plant.local/video.mjpg'],
1750
+ }),
1751
+ credentials: cameraCredentials.optional(),
1752
+ }).meta({
1753
+ description: 'Frames come from an MJPEG stream over HTTP — one JPEG after another, the simplest network source there is. Unlike `rtsp` there is no `transport` to choose: it is HTTP, and any `credentials` therefore travel as HTTP Basic.',
1754
+ defaultSnippets: [{
1755
+ label: 'mjpeg — one JPEG after another over HTTP',
1756
+ description: 'The simplest network source there is. `https://` is accepted too, and is what any credentials on this URL need.',
1757
+ body: {
1758
+ kind: 'mjpeg',
1759
+ url: 'http://${1:cam-1.plant.local}/${2:video.mjpg}',
1760
+ },
1761
+ }],
1762
+ }),
1763
+ strictObject({
1764
+ kind: z.literal('v4l2').meta({
1765
+ description: 'Selects the local capture-device source: this camera then carries `device` and nothing else.',
1766
+ }),
1767
+ /**
1768
+ * e.g. `/dev/video0`, or a stable `/dev/v4l/by-id/...` symlink. Resolved
1769
+ * on the robot, never by the cloud.
1770
+ *
1771
+ * Constrained to `/dev/` for the same reason the `rtsp` and `mjpeg` URLs
1772
+ * are constrained to their schemes, and it was missed the first time
1773
+ * (Momus, W6 verification). The device string reaches
1774
+ * `cv2.VideoCapture(device)` on the robot, and OpenCV does not restrict
1775
+ * itself to devices: measured on cv2 4.5.4, an ordinary local video file
1776
+ * opens and its pixels are published to the cloud, and so does
1777
+ * `http://127.0.0.1:8899/secret.jpg`. Unconstrained, this field is an
1778
+ * arbitrary local-file read *and* an outbound fetch from inside the robot
1779
+ * — the §7.6 violation closed for the other two source kinds, reachable
1780
+ * through the fourth, because "it is just a device path" read like a
1781
+ * reason not to check.
1782
+ *
1783
+ * Narrower than the URL hole in one respect worth recording: a non-media
1784
+ * file and a missing file both fail to open, so this branch never worked
1785
+ * as a file-existence oracle.
1786
+ *
1787
+ * The bridge re-derives this constraint rather than trusting the wire
1788
+ * (`validate_device_path`), exactly as it re-derives the URL scheme.
1789
+ */
1790
+ device: z
1791
+ .string()
1792
+ .min(1)
1793
+ .max(128)
1794
+ .regex(/^\/dev\/[A-Za-z0-9][A-Za-z0-9._/-]*$/, DEVICE_PATH_RULE)
1795
+ .refine((v) => !v.split('/').includes('..'), 'must not contain a `..` path segment')
1796
+ .refine((v) => !v.endsWith('/'), 'must name a device, not a directory')
1797
+ .meta({
1798
+ description: 'The capture device, resolved on the robot and never by the cloud; a `/dev/v4l/by-id/...` symlink survives a reboot that renumbers `/dev/video0`. **Constrained to `/dev/`** — the string reaches OpenCV, which will just as happily open an ordinary video file or an `http://` URL and publish its pixels to the cloud. The bridge re-derives the same constraint rather than trusting the wire.',
1799
+ patternErrorMessage: DEVICE_PATH_RULE,
1800
+ examples: ['/dev/video0'],
1801
+ }),
1802
+ }).meta({
1803
+ description: 'Frames come from a capture device attached to the robot itself, such as a USB camera on `/dev/video0`. Nothing leaves the robot to fetch them, and there is nothing to authenticate to, so this source takes no `credentials`.',
1804
+ defaultSnippets: [{
1805
+ label: 'v4l2 — a capture device attached to the robot',
1806
+ description: 'A USB camera on the robot itself. A `/dev/v4l/by-id/...` symlink survives a reboot that renumbers `/dev/video0`.',
1807
+ /**
1808
+ * The default is not decoration. `device` is required and the path is
1809
+ * constrained to `/dev/`, so a bare `$1` would insert the empty string
1810
+ * and offer a camera the format refuses.
1811
+ */
1812
+ body: {
1813
+ kind: 'v4l2',
1814
+ device: '${1:/dev/video0}',
1815
+ },
1816
+ }],
1817
+ }),
1818
+ ]);
1819
+ /**
1820
+ * How often a snapshot is captured, in seconds. Bounded below at one second
1821
+ * because a snapshot is the *cheap* mode — a developer who wants motion wants
1822
+ * live, and an interval faster than this is a live stream wearing a disguise.
1823
+ *
1824
+ * The bound lives here once, and `rest.ts`'s `cameraDescriptor` reuses it —
1825
+ * the same treatment `rateThrottleHz` got, and for the same reason: the
1826
+ * descriptor used to say `snapshot_interval_ms` while the document said
1827
+ * seconds, so the cloud converted on one descriptor and not its sibling, with
1828
+ * nothing in either file saying so.
1829
+ */
1830
+ export const snapshotIntervalSeconds = z.number().int().min(1).max(3600);
1831
+ /**
1832
+ * One camera, authored once for the two positions it is offered from:
1833
+ * `cameras:` (under `underSlug`) and the value of one entry below it.
1834
+ *
1835
+ * Every field of `cameraConfig` except `description` is required, so the body
1836
+ * carries all of them; the four `source` kinds each offer their own skeleton at
1837
+ * `source:` itself, and `v4l2` is the one here because a device path is the
1838
+ * only source a robot can be assumed to have without a network.
1839
+ */
1840
+ const CAMERA_SNIPPET = {
1841
+ label: 'a camera',
1842
+ description: 'A complete camera entry with every required field.',
1843
+ body: {
1844
+ source: { kind: 'v4l2', device: '${2:/dev/video0}' },
1845
+ width: 1280,
1846
+ height: 720,
1847
+ fps: 15,
1848
+ bitrate_kbps: 2000,
1849
+ snapshot_interval_seconds: 5,
1850
+ description: '${3:Forward-facing camera on the mast.}',
1851
+ },
1852
+ };
1853
+ /**
1854
+ * A camera the robot exposes (spec §10).
1855
+ *
1856
+ * `width`/`height`/`fps`/`bitrate_kbps` are not cosmetic: §10 makes them the
1857
+ * developer's control over **the robot's own bandwidth**, which is why they
1858
+ * live in the configuration rather than in a viewer's request. A viewer never
1859
+ * gets to make a robot send more.
1860
+ *
1861
+ * The two modes are deliberately independent (§10):
1862
+ *
1863
+ * - **Snapshot** runs always, at `snapshot_interval_seconds`, whether or not
1864
+ * anyone is watching live. The cloud caches the one frame and serves every
1865
+ * client from it, so a hundred pollers cost the robot exactly one image per
1866
+ * interval.
1867
+ * - **Live** runs on demand and is refcounted in the cloud: the first viewer
1868
+ * starts it, the last one ends it.
1869
+ */
1870
+ export const cameraConfig = strictObject({
1871
+ source: cameraSource.meta({
1872
+ description: 'Where this camera\'s frames come from. `kind` picks one of four sources and fixes which other fields the source may carry, so an impossible camera is unrepresentable rather than merely invalid — there is no way to write an RTSP camera with a ROS topic.',
1873
+ }),
1874
+ width: z.number().int().positive().max(7680).meta({
1875
+ description: 'The width the bridge scales frames to before sending, in pixels — what the bridge produces, not what the sensor captures; a snapshot can arrive narrower, since the bridge reduces both dimensions together to fit its JPEG byte ceiling. It stands in the configuration and never in a viewer\'s request, so no client can make the robot encode a larger frame than the developer allowed.',
1876
+ examples: [1280],
1877
+ }),
1878
+ height: z.number().int().positive().max(4320).meta({
1879
+ description: 'The height the bridge scales every frame to, in pixels; with `width` it is the size the live stream carries. A snapshot can arrive **smaller** than this — its JPEG has a byte ceiling, and the bridge gives up quality first and then resolution to fit, reporting the size it actually encoded.',
1880
+ examples: [720],
1881
+ }),
1882
+ fps: z.number().int().positive().max(60).meta({
1883
+ description: 'How many frames a second the bridge forwards, at most. It is a ceiling, not a clock: a camera that delivers ten frames a second stays at ten. Both modes read the same throttled pipeline, so this also bounds how fresh a snapshot can be.',
1884
+ examples: [15],
1885
+ }),
1886
+ bitrate_kbps: z.number().int().positive().max(50_000).meta({
1887
+ description: 'The ceiling for the **live** encoding, in kilobits per second — this is what bounds a watched camera against the robot\'s uplink. Snapshots are not covered by it: they are JPEGs under their own byte ceiling. Raising `width`, `height` or `fps` against a fixed bitrate buys blur, not detail.',
1888
+ examples: [2000],
1889
+ }),
1890
+ snapshot_interval_seconds: snapshotIntervalSeconds.meta({
1891
+ description: 'How often a still frame is captured, in seconds. **It runs whether or not anyone is watching**, unlike the live stream, which the cloud refcounts — first viewer starts it, last one ends it. The cloud caches the one frame and serves every reader from it, so a hundred pollers cost the robot exactly one image per interval.',
1892
+ examples: [5],
1893
+ }),
1894
+ description: serviceDescription.meta({
1895
+ description: 'What this camera shows, in the developer\'s own words — documentation for whoever reads the configuration, for the console and for MCP clients; the robot does nothing with it. A camera without one is still offered, with `description: null`, as for actions, services and publishers. What `camera_snapshot` serves is the latest snapshot with its age; a live session is never a tool.',
1896
+ examples: ['Forward-facing camera on the mast.'],
1897
+ }),
1898
+ }).meta({
1899
+ /** The value position of one entry — `front: ▮` under `cameras:`. */
1900
+ defaultSnippets: [CAMERA_SNIPPET],
1901
+ });
1902
+ /**
1903
+ * The format version of a `fleetless.yaml`. Deliberately not called
1904
+ * `version`: the console counts published states with "v12 → v13", and two
1905
+ * numbers called version would be the likeliest confusion in the format.
1906
+ */
1907
+ export const FLEETLESS_FORMAT_VERSION = 1;
1908
+ const capped = (entry, max, what) => slugKeyed(entry).refine((m) => Object.keys(m).length <= max, { message: `at most ${max} ${what}` });
1909
+ /**
1910
+ * A whole robot configuration — everything configurable about one robot.
1911
+ *
1912
+ * Every section is a mapping keyed by name, not a list of objects carrying
1913
+ * their own name. A duplicate name is then a YAML syntax error rather than a
1914
+ * rule somebody has to write, and the name reads as the entry's heading.
1915
+ *
1916
+ * Slugs remain ONE namespace across all five exposure sections (§4.1), which
1917
+ * is what lets a role grant say `{robot, slug}` without naming a kind. That
1918
+ * check spans sections and therefore lives in the cloud, not here.
1919
+ */
1920
+ export const robotConfigDoc = strictObject({
1921
+ fleetless: z.literal(FLEETLESS_FORMAT_VERSION).meta({
1922
+ description: 'The format version, and the first line of the file. It decides how everything below is read, so a file that omits it — or names a version this cloud does not know — is **refused rather than half understood**.',
1923
+ }),
1924
+ messages: messageMap.meta({
1925
+ description: 'Reusable message bodies, keyed by name, inserted elsewhere by writing `${name}` directly after `message:`. A shared body may hold placeholders and whoever inserts it declares the parameters, so two publishers can send the same message under different bounds. **A shared message may not insert another**, so a `${name}` inside a body is always a parameter and never a second message.',
1926
+ defaultSnippets: [underSlug('${1:drive}', SHARED_MESSAGE_SNIPPET)],
1927
+ }).optional(),
1928
+ datapoints: capped(datapointConfig, 200, 'datapoints').meta({
1929
+ description: 'Values the robot publishes, each one field of one topic or a whole topic, and **never several topics**. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state`, `robot_details` and `bridge_pressure` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all four are refused when the document is validated.',
1930
+ defaultSnippets: [
1931
+ underSlug('${1:battery_voltage}', DATAPOINT_SNIPPET),
1932
+ underSlug('${1:battery}', NUMERIC_DATAPOINT_SNIPPET),
1933
+ ],
1934
+ }).optional(),
1935
+ actions: capped(actionConfig, 200, 'actions').meta({
1936
+ description: 'Things the robot does on request that take time, each reported as a job with progress. **At most one job runs per action slug**: a second call is refused `busy`, and every observer of that slug watches the same job. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state`, `robot_details` and `bridge_pressure` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all four are refused when the document is validated.',
1937
+ defaultSnippets: [underSlug('${1:navigate}', ACTION_SNIPPET)],
1938
+ }).optional(),
1939
+ services: capped(serviceConfig, 200, 'services').meta({
1940
+ description: 'ROS service calls the robot answers — one request, one reply. Unlike an action a service reports **no progress** and the call returns with its result already on the job, so there is nothing left to observe; a second concurrent call is still refused `busy`, exactly as for an action. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state`, `robot_details` and `bridge_pressure` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all four are refused when the document is validated.',
1941
+ defaultSnippets: [underSlug('${1:reset_odometry}', SERVICE_SNIPPET)],
1942
+ }).optional(),
1943
+ publishers: capped(publisherConfig, 200, 'publishers').meta({
1944
+ description: 'Topics clients may send to, and where the format\'s whole safety story lives. The `message` template fixes every value a caller cannot change, and **`failsafe` is required**: once a client falls silent the bridge sends the failsafe message itself, so an operator whose window closed does not leave a robot driving. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state`, `robot_details` and `bridge_pressure` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all four are refused when the document is validated.',
1945
+ defaultSnippets: [underSlug('${1:drive}', PUBLISHER_SNIPPET)],
1946
+ }).optional(),
1947
+ cameras: capped(cameraConfig, 50, 'cameras').meta({
1948
+ description: 'Video the robot streams, and the still frames the cloud serves from it. `width`, `height`, `fps` and `bitrate_kbps` are what **the bridge produces before sending**, not what the camera captures — they live in the configuration rather than in a viewer\'s request precisely so that no viewer can make a robot send more. Keys are slugs, one namespace across all five exposure sections, which is what lets a role grant say `{robot, slug}` without naming a kind; `bridge_state`, `robot_details` and `bridge_pressure` are built-in, and `history` is reserved because `GET …/jobs/history` would shadow an action of that name; all four are refused when the document is validated.',
1949
+ defaultSnippets: [underSlug('${1:front}', CAMERA_SNIPPET)],
1950
+ }).optional(),
1951
+ });
1952
+ /**
1953
+ * One thing the cloud has to say about a configuration (spec §11.5: field +
1954
+ * violated rule).
1955
+ *
1956
+ * `error` blocks the publish. `warning` does not — an unknown topic is a
1957
+ * warning on purpose, because configuring a robot that has never been
1958
+ * connected must stay possible (spec §4.1).
1959
+ */
1960
+ export const validationIssue = z.object({
1961
+ path: z.string().min(1),
1962
+ slug: z.string().nullable(),
1963
+ code: z.string().min(1),
1964
+ message: z.string().min(1),
1965
+ severity: z.enum(['error', 'warning']),
1966
+ });
1967
+ /**
1968
+ * Where a robot's configuration stands — the material for the console's
1969
+ * "draft newer than published", "published v2 · applied v1 · bridge offline"
1970
+ * (spec §15.2, robot tab 1).
1971
+ */
1972
+ export const configState = z.object({
1973
+ published_version: z.number().int().positive().nullable(),
1974
+ published_at: z.iso.datetime().nullable(),
1975
+ draft_updated_at: z.iso.datetime().nullable(),
1976
+ applied_version: z.number().int().nonnegative().nullable(),
1977
+ applied_ok: z.boolean().nullable(),
1978
+ /**
1979
+ * The bridge's own `bridgeConfigApplied.errors` (`protocol.ts`), read back
1980
+ * verbatim. **Reuses `applyError` rather than restating `{ slug, message
1981
+ * }`** — a narrower local copy here used to silently strip `kind`, `code`
1982
+ * and `details` on every read: `configState.safeParse` dropped every field
1983
+ * a caller did not ask for, and `robotDetailResponse` embeds `configState`
1984
+ * (`useCloudApi.ts`'s `getRobot`), so the console lost the fields one
1985
+ * layer before anyone could see them.
1986
+ */
1987
+ applied_errors: z.array(applyError).nullable(),
1988
+ });