@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/rest.js ADDED
@@ -0,0 +1,1963 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ import { z } from 'zod';
3
+ import { bridgeState, MAX_PATIENCE_MS, MIN_PATIENCE_MS } from './protocol.js';
4
+ import { slug, rosTypeName, wireTimestampMs } from './common.js';
5
+ import { configState, rateThrottleHz, robotConfigDoc, snapshotIntervalSeconds, validationIssue } from './config.js';
6
+ import { rosGraph, typeDefinition } from './introspection.js';
7
+ import { job } from './jobs.js';
8
+ /**
9
+ * REST shapes of the robot resource (spec §11.1). W1 scope: create, list,
10
+ * get, and the built-in `bridge_state` datapoint read.
11
+ */
12
+ export const robot = z.object({
13
+ id: z.uuid().meta({
14
+ description: 'The robot, and what every robot-scoped route takes as its `:id`.',
15
+ }),
16
+ name: z.string().min(1).max(63).meta({
17
+ description: 'The robot\'s display name, at most 63 characters. Free text, changed through `PATCH /api/robots/:id`.',
18
+ }),
19
+ created_at: z.iso.datetime().meta({
20
+ description: 'When the robot was created, as an ISO 8601 timestamp.',
21
+ }),
22
+ });
23
+ /** What `PATCH /api/robots/:id` answers: the robot as it now stands. */
24
+ export const patchRobotResponse = z.object({
25
+ robot: robot.meta({
26
+ description: 'The robot as it now stands, after the patch was applied. The whole resource comes back, not only the fields that changed.',
27
+ }),
28
+ });
29
+ export const createRobotRequest = z.object({
30
+ name: z.string().min(1).max(63),
31
+ });
32
+ /**
33
+ * The robot token binds one bridge to one robot (spec §5). It is returned
34
+ * exactly once, here; the cloud stores only a hash of it.
35
+ */
36
+ export const robotToken = z.string().regex(/^frt_[0-9a-f]{32}$/);
37
+ export const createRobotResponse = z.object({
38
+ robot,
39
+ token: robotToken,
40
+ });
41
+ /**
42
+ * How many things a robot exposes, per kind (spec `2026-08-21-exposure-and-revoke-design` D1).
43
+ *
44
+ * **Five numbers, never a sum.** `robotDeletionSummary.slug_count` already made
45
+ * this call and wrote down why: fold cameras in and the sentence "this deletes
46
+ * N slugs and M cameras" counts them twice. A list row has the same problem.
47
+ *
48
+ * **Counted from the published configuration, and excluding the built-ins.**
49
+ * `GET /api/robots/:id/exposures` answers *which* slugs and prepends the
50
+ * three built-in datapoints — `bridge_state`, `robot_details` and
51
+ * `bridge_pressure` — as `builtin: true`; this answers *how many* and counts
52
+ * only what somebody configured. So a robot with an empty published config
53
+ * reports `datapoints: 0` here and three entries there. That is intentional,
54
+ * and it is written on both sides so the disagreement is never mistaken for a
55
+ * bug.
56
+ *
57
+ * The number is "three" and not "two" as of `bridge_pressure`; the cloud
58
+ * builds that prefix from `PLANE_BUILTIN_DATAPOINTS` rather than a literal,
59
+ * so a further built-in moves this count again. Read the count off that set,
60
+ * not off this sentence, before filing the bug this comment exists to
61
+ * prevent.
62
+ */
63
+ export const exposureCounts = z.object({
64
+ datapoints: z.number().int().nonnegative(),
65
+ actions: z.number().int().nonnegative(),
66
+ services: z.number().int().nonnegative(),
67
+ publishers: z.number().int().nonnegative(),
68
+ cameras: z.number().int().nonnegative(),
69
+ });
70
+ /** A robot as listed, with its current built-in `bridge_state`. */
71
+ export const robotListItem = z.object({
72
+ ...robot.shape,
73
+ bridge_state: bridgeState,
74
+ /** Required, not optional: "we did not look" and "it exposes nothing" must not render the same. */
75
+ exposes: exposureCounts,
76
+ });
77
+ export const robotListResponse = z.object({
78
+ robots: z.array(robotListItem),
79
+ });
80
+ /**
81
+ * The REST read of one datapoint. For bridge-captured data `timestamp_ms`
82
+ * is the capture time at the bridge (spec §6.3); for the cloud-observed
83
+ * built-in `bridge_state` it is the time the cloud observed the state.
84
+ */
85
+ export const datapointValue = z.object({
86
+ slug: slug.meta({ description: 'The datapoint this value belongs to.' }),
87
+ value: z.unknown().meta({
88
+ description: 'The value itself, shaped by the datapoint: a number, a boolean, a string, or the whole ROS message where the configuration names no field inside it. Any `scale` and `offset` the configuration declares have already been applied, at the robot.',
89
+ }),
90
+ timestamp_ms: z.number().int().nonnegative().meta({
91
+ description: 'When the value was captured, as a unix timestamp in milliseconds. This is the **bridge\'s capture time**, never the time the cloud received it — the one exception is the built-in `bridge_state`, which the cloud observes by construction.',
92
+ }),
93
+ });
94
+ /* ------------------------------------------------------------------ W2 --
95
+ * Exposure: the configuration resource, introspection, types, and the
96
+ * datapoint surface generated from the published configuration (spec §4,
97
+ * §11.2).
98
+ */
99
+ /**
100
+ * One robot in full: what the list shows, plus what only the detail view
101
+ * needs — which bridge build is connected, why the last hello was refused,
102
+ * and where the configuration stands (spec §15.2, tab 1).
103
+ */
104
+ export const robotDetailResponse = z.object({
105
+ ...robotListItem.shape,
106
+ bridge_version: z.string().min(1).nullable(),
107
+ /**
108
+ * Cleared (set back to null) by the next successful hello from this
109
+ * robot's bridge — a warning that outlives the condition it warns
110
+ * about would be read as current state, and was.
111
+ */
112
+ last_hello_error: z
113
+ .object({
114
+ code: z.string().min(1),
115
+ message: z.string().min(1),
116
+ at: z.iso.datetime(),
117
+ })
118
+ .nullable(),
119
+ config: configState,
120
+ });
121
+ /**
122
+ * The editable configuration. `issues` is recomputed on every read and
123
+ * write, so the editor never has to guess whether it may publish.
124
+ *
125
+ * **`source` is the author's text and `doc` is what it parses to.** Both are
126
+ * sent because they answer different questions: an editor renders the text a
127
+ * developer wrote, comments and key order intact, while every other consumer —
128
+ * the robot page, the MCP tools, the bridge frame — reads the parsed document
129
+ * and should never have to parse YAML to do it.
130
+ *
131
+ * **`doc` is null when the text is valid YAML but not a fleetless document.**
132
+ * A draft is saved whenever it parses as YAML; publish is the gate that asks
133
+ * for a document. So a stored draft can genuinely have no document, and `null`
134
+ * says exactly that: *this text does not currently parse to a configuration*.
135
+ * It does **not** mean "nothing is configured" — the last published version is
136
+ * untouched — and a reader that renders a tree from `doc` has to tell those two
137
+ * apart before it draws anything.
138
+ *
139
+ * The alternative was to put the raw parsed YAML value in `doc`. It was
140
+ * rejected because a reader could then no longer tell whether what it holds is
141
+ * a document: every consumer would have to re-validate to find out, and the one
142
+ * that forgot would render a stranger's mapping as a configuration. `null`
143
+ * forces the question at the point of reading.
144
+ *
145
+ * `source` is never null, and that is what makes the pair `doc: null,
146
+ * source: null` unrepresentable here rather than merely discouraged. A draft
147
+ * exists from the moment a robot does, before anyone has typed anything; for
148
+ * that one the server renders the document instead, so a reader always has text
149
+ * to show and — when there is no document — always has the text that failed to
150
+ * become one.
151
+ */
152
+ export const configDraftResponse = z.object({
153
+ doc: robotConfigDoc.nullable(),
154
+ source: z.string(),
155
+ updated_at: z.iso.datetime().nullable(),
156
+ issues: z.array(validationIssue),
157
+ });
158
+ /**
159
+ * A write carries the **text only**, and that is the point.
160
+ *
161
+ * If it carried both the text and the parsed document, the two could
162
+ * disagree. Sending only the source makes that unrepresentable on the wire:
163
+ * the server parses it, and there is exactly one account of what the
164
+ * configuration says.
165
+ *
166
+ * It also settles who owns parsing, and **FL-005 D2 moved that line**. The
167
+ * sentence here used to read that the console refuses unparsable YAML before it
168
+ * sends, so a syntax error never reaches the server. That is no longer the
169
+ * rule: the **server** refuses text that is not valid YAML, with the line and
170
+ * column, and stores everything else — including valid YAML that is not a
171
+ * fleetless document, which comes back with `doc: null` and its issues. The
172
+ * console checks as you type so the answer is immediate; the server checks
173
+ * because it is the one that decides. Two checks of one question, and the
174
+ * server's is the one that binds.
175
+ *
176
+ * The pair that used to be called a defect — a stored source that does not
177
+ * parse to its stored document — is now a **represented state**: no document at
178
+ * all. See `configDraftResponse` above.
179
+ */
180
+ export const putConfigDraftRequest = z.object({ source: z.string().max(1_000_000) });
181
+ /** Publishing freezes the draft into the next immutable version. */
182
+ export const publishConfigResponse = z.object({
183
+ version: z.number().int().positive(),
184
+ published_at: z.iso.datetime(),
185
+ });
186
+ export const configVersionsResponse = z.object({
187
+ versions: z.array(z.object({
188
+ version: z.number().int().positive(),
189
+ published_at: z.iso.datetime(),
190
+ })),
191
+ });
192
+ /**
193
+ * One published version, with the text it was published from.
194
+ *
195
+ * The text is what makes a version diff readable and a restore honest: a
196
+ * restore that returned only the document would hand back a configuration
197
+ * stripped of every comment the author wrote, which is the loss this format
198
+ * exists to prevent.
199
+ */
200
+ export const configVersionResponse = z.object({
201
+ version: z.number().int().positive(),
202
+ published_at: z.iso.datetime(),
203
+ doc: robotConfigDoc,
204
+ source: z.string(),
205
+ });
206
+ /**
207
+ * The cached ROS graph. It survives the bridge going offline on purpose —
208
+ * a developer keeps configuring while the robot is off; `stale` says the
209
+ * bridge is not connected right now, `fetched_at` how old the picture is.
210
+ */
211
+ export const introspectionResponse = z.object({
212
+ graph: rosGraph,
213
+ fetched_at: z.iso.datetime(),
214
+ stale: z.boolean(),
215
+ });
216
+ export const typesResponse = z.object({
217
+ types: z.array(typeDefinition),
218
+ });
219
+ /** Fetch (and store) type definitions for this robot from its bridge. */
220
+ export const fetchTypesRequest = z.object({
221
+ type_names: z.array(rosTypeName).min(1).max(50),
222
+ });
223
+ export const fetchTypesResponse = z.object({
224
+ types: z.array(typeDefinition),
225
+ unresolved: z.array(z.string()),
226
+ });
227
+ /**
228
+ * What a client can read on this robot: the built-ins plus everything the
229
+ * published configuration exposes. This is the seed of the generated
230
+ * per-robot API (§11.2).
231
+ *
232
+ * **The OpenAPI rendering exists since the route manifest (`routes.ts`):
233
+ * `artifacts/openapi.json`, derived from the manifest and these schemas by
234
+ * `scripts/export-schemas.ts`.**
235
+ */
236
+ export const datapointDescriptor = z.object({
237
+ slug: slug.meta({ description: 'The name a client reads this datapoint by.' }),
238
+ builtin: z.boolean().meta({
239
+ description: '`true` for the datapoints every robot has — `bridge_state`, `robot_details` and `bridge_pressure` — and `false` for everything the published configuration adds.',
240
+ }),
241
+ unit: z.string().nullable().meta({
242
+ description: 'The unit the value carries **after** any scale and offset, shown beside the number so nobody has to guess whether `15` means percent, volts or minutes. `null` when the configuration names none.',
243
+ }),
244
+ /**
245
+ * `null` for a built-in and for a datapoint published with no throttle —
246
+ * the same "no ceiling configured" fact `datapointConfig.rate_throttle_hz`
247
+ * itself carries as `0` or absence, just re-spelled nullable rather than
248
+ * optional because this shape is a read response, not a document a caller
249
+ * writes. Reuses `rateThrottleHz` so the 20 Hz ceiling is written once.
250
+ */
251
+ rate_throttle_hz: rateThrottleHz.nullable().meta({
252
+ description: 'The ceiling on how often this datapoint is sent, in hertz. `null` means no ceiling is configured, which is also the answer for every built-in. A ceiling, not a clock: a slow topic stays slow and no value is repeated to manufacture a rate.',
253
+ }),
254
+ });
255
+ export const datapointListResponse = z.object({
256
+ datapoints: z.array(datapointDescriptor).meta({
257
+ description: 'Everything a client may read on this robot: the three built-ins, plus every datapoint the published configuration exposes and the caller\'s role grants.',
258
+ }),
259
+ });
260
+ /**
261
+ * The built-in `robot_details` datapoint (spec §4.3): static properties the
262
+ * developer maintains. Bounded so one robot cannot become a document store.
263
+ */
264
+ export const robotDetailsDoc = z.record(z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/), z.union([z.string().max(4096), z.number(), z.boolean(), z.array(z.unknown()), z.record(z.string(), z.unknown())]));
265
+ /** What `PUT /api/robots/:id/details` answers: the stored document, which is the one that was sent. */
266
+ export const putRobotDetailsResponse = z.object({
267
+ details: robotDetailsDoc.meta({
268
+ description: 'The stored `robot_details` document, which is the one that was just sent — this route **replaces** the document rather than merging into it. Keys are the developer\'s own, lowercase and at most 64 characters; a value is a string of at most 4096 characters, a number, a boolean, an array or an object.',
269
+ }),
270
+ });
271
+ export const putRobotDetailsRequest = z.object({ details: robotDetailsDoc });
272
+ /* ------------------------------------------------------------------ W4 --
273
+ * The command surface (spec §11.1, §11.3) and what a role may be granted.
274
+ *
275
+ * The routes, written down because cloud, console and SDK each need them and
276
+ * a body schema does not imply a path:
277
+ *
278
+ * | route | body | answers |
279
+ * |---|---|---|
280
+ * | `POST /api/robots/:id/jobs/:slug` | `invokeRequest` | `invokeResponse` (action) or `serviceCallResponse` (service) |
281
+ * | `GET /api/robots/:id/jobs/:slug` | — | `jobResponse` (the current job, or null) |
282
+ * | `POST /api/robots/:id/jobs/:slug/cancel` | `cancelRequest` | `jobResponse` |
283
+ * | `POST /api/robots/:id/publishers/:slug` | `publishRequest`| 204 |
284
+ * | `GET /api/robots/:id/exposures` | — | `exposureListResponse` |
285
+ *
286
+ * **Commands are addressed by slug, never by kind.** Slugs are one namespace
287
+ * across all kinds (§4.1) and a role grant is `{robot, slug}` with no kind in
288
+ * it — so a path segment naming the kind would demand a fact the permission
289
+ * model deliberately does not carry. The cloud already knows from the
290
+ * published configuration whether a slug is an action or a service; a caller
291
+ * who wants to know asks `/exposures`.
292
+ *
293
+ * That is also why invoking and calling are the same route: both create the
294
+ * job on that slug. They differ only in what the cloud waits for before it
295
+ * answers — a service call awaits the terminal update and returns the result
296
+ * inline, an action returns as soon as the job exists. Publishing is not a
297
+ * job and so is not under `/jobs`.
298
+ */
299
+ /**
300
+ * Invoke an action or call a service; parameters by field path (§4.4).
301
+ *
302
+ * Flat, keyed by `parameterSpec.name` — see `cloudInvoke.params` for why the
303
+ * flat form is the one that makes a refusal legible.
304
+ */
305
+ export const invokeRequest = z.object({
306
+ params: z.record(z.string(), z.unknown()).meta({
307
+ description: 'The values this call needs, keyed by **parameter name** rather than by field path — so a name survives the field moving inside the message. Every parameter without a default must be present, and the bounds the configuration declares are enforced in the cloud, before anything reaches the robot.',
308
+ }),
309
+ /**
310
+ * How long **this call** is worth waiting for, in milliseconds (W6b).
311
+ *
312
+ * **Absent means `DEFAULT_PATIENCE_MS`** — today's behaviour, unchanged, for
313
+ * every caller who does not care. It is optional because most callers have
314
+ * no opinion, and forcing one on them would mean every SDK example carries a
315
+ * number its author guessed.
316
+ *
317
+ * It exists because patience was a **server constant** and could therefore
318
+ * only ever be wrong in one of two directions at a time: long enough for a
319
+ * planner meant a dead service also took that long to report, and short
320
+ * enough for a snappy lookup meant a legitimate slow job was reported as
321
+ * `bridge_timeout` — a healthy robot, described as broken, with nothing the
322
+ * caller could do about it.
323
+ *
324
+ * The number travels with the call to the bridge (`cloudInvoke.patience_ms`)
325
+ * so that **one** deadline governs both sides. Capped at
326
+ * `MAX_PATIENCE_MS`; above that the call is refused with
327
+ * `validation_error` rather than silently clamped, because a caller who
328
+ * asked for ten minutes and was quietly given two would read the timeout as
329
+ * the robot's failure.
330
+ *
331
+ * For a service call this is the whole wait. For an action it bounds goal
332
+ * *acceptance* — once a goal is accepted the job runs as long as it runs,
333
+ * and is observed, not awaited.
334
+ */
335
+ patience_ms: z.number().int().min(MIN_PATIENCE_MS).max(MAX_PATIENCE_MS).optional().meta({
336
+ description: 'How long **this call** is worth waiting for, in milliseconds; absent means the platform default. The number travels to the robot too, so one deadline governs both sides. Above the maximum the call is refused rather than quietly clamped, because a caller given less than they asked for would read the timeout as the robot\'s failure.',
337
+ }),
338
+ });
339
+ /**
340
+ * The answer to an invoke. The job id is informative (§11.3): state is
341
+ * observed by slug afterwards, over polling or a subscription.
342
+ */
343
+ /**
344
+ * The body of a cancel (W6b). **Every field optional, and the body itself may
345
+ * be absent** — `POST .../cancel` was bodyless before this wave and every
346
+ * existing caller still sends nothing.
347
+ *
348
+ * That is not politeness, it is the W5 defect: a bodyless `POST` carrying
349
+ * `content-type: application/json` was rejected outright, which made
350
+ * `cameras.live()` unreachable through the SDK and took `cancel`, publish,
351
+ * restore, key rotation and member removal with it — unnoticed since W4. A
352
+ * schema that demands a body would reintroduce it on the one verb that stops
353
+ * a machine.
354
+ *
355
+ * **`.strict()`, and that is the whole point of the shape.** A plain object
356
+ * strips unknown keys, so a caller who *means* to name a job and misspells the
357
+ * field — `jobId` for `job_id` — has their id silently removed and gets the
358
+ * **slug-wide** cancel instead: the most destructive reading of a request they
359
+ * did not make. Measured in W6b's review: `{"jobId": "<some other job>"}`
360
+ * answered `200` and stopped the job that was actually running, which nobody
361
+ * had named. The `?force=true` precedent this route's design borrowed from
362
+ * fails *safe* on a typo — a misspelled `force` simply does not force.
363
+ * Stripping here fails unsafe, so unknown keys are refused instead.
364
+ *
365
+ * `job_id` absent and `job_id: null` mean the **same** thing here, and that is
366
+ * deliberate: over REST an absent body is how every caller written before this
367
+ * wave says "cancel whatever is running". On the socket, `clientCancel.job_id`
368
+ * is required-and-nullable instead, because a frame is assembled fresh by a
369
+ * client that has already been updated — there, `null` is a decision and an
370
+ * omission is a bug.
371
+ */
372
+ export const cancelRequest = z.object({
373
+ job_id: z.uuid().nullable().optional().meta({
374
+ description: 'The one job to stop. Absent or `null` cancels whatever is currently running on the slug, which is what every caller written before this field existed means. Unknown keys are refused rather than stripped, so a misspelling cannot silently become the slug-wide cancel.',
375
+ }),
376
+ }).strict();
377
+ /**
378
+ * The query of a live release (W6b): `DELETE .../live?session_id=<uuid>`.
379
+ *
380
+ * A query parameter rather than a body, following `?force=true` on robot
381
+ * deletion — the precedent this repo already set for "a DELETE that needs one
382
+ * more fact". A body on a DELETE is carried inconsistently by proxies and by
383
+ * `fetch` itself, and this call runs from a browser tab that is often closing.
384
+ *
385
+ * **`.strict()`, for the reason `cancelRequest` is** — `?sessionid=` instead of
386
+ * `?session_id=` was measured releasing **both** of an identity's holds and
387
+ * stranding the other tab, which is precisely the defect this field was added
388
+ * to remove. A refused typo costs a round trip; a stripped one stops a robot
389
+ * somebody else is watching.
390
+ *
391
+ * Absent means today's meaning: release **all** of this identity's holds on
392
+ * this camera. A client that has lost its id, or is going away entirely, still
393
+ * needs a way to let go — it is the blunt form, and it is the one that strands
394
+ * the identity's other tabs.
395
+ */
396
+ export const releaseLiveQuery = z.object({
397
+ session_id: z.uuid().optional().meta({
398
+ description: 'The one hold to release, as the live session returned it. Absent releases **all** of this identity\'s holds on this camera — the blunt form, still needed by a client that has lost its id or is going away, and the one that strands the identity\'s other tabs.',
399
+ }),
400
+ }).strict();
401
+ export const invokeResponse = z.object({
402
+ job: job.meta({
403
+ description: 'The job that now exists on this slug. It is returned as soon as the goal is accepted, so `state` is `running` here — the outcome is observed afterwards, by slug, over polling or a subscription.',
404
+ }),
405
+ /** The slug's kind — see `commandResult.kind` for why the caller needs it. */
406
+ kind: z.enum(['action', 'service']).meta({
407
+ description: 'Always `action` in this shape. A caller sends the same request for both kinds and cannot tell from a role grant which it invoked, so the answer says which it was rather than leaving it to be inferred from the shape.',
408
+ }),
409
+ });
410
+ /** A service call answers with its result directly — no job to observe. */
411
+ export const serviceCallResponse = z.object({
412
+ result: z.unknown().meta({
413
+ description: 'What the service returned, shaped by the ROS service itself. A service call is awaited to completion, so there is no job to observe afterwards and no id to hold on to.',
414
+ }),
415
+ });
416
+ /**
417
+ * **What `POST /api/robots/:id/jobs/:slug` answers, which is one of two
418
+ * shapes.**
419
+ *
420
+ * One route serves both kinds, because a path segment naming the kind would
421
+ * demand a fact a role grant does not carry. **The slug's kind decides, and
422
+ * nothing in the request does**: an *action* answers `202` with an
423
+ * `invokeResponse` the moment the job exists, a *service* answers `200` with
424
+ * a `serviceCallResponse` once the result is in. They differ only in what the
425
+ * cloud waits for before it answers.
426
+ *
427
+ * The two are told apart without inspecting the status code: `invokeResponse`
428
+ * carries `kind` and `job`, `serviceCallResponse` carries `result` alone.
429
+ *
430
+ * **This union exists so the route can name a response at all.** The entry
431
+ * carried `response: null` while the handler demonstrably answers something,
432
+ * which reads in the generated reference as *this route returns nothing* —
433
+ * the documented absence this project keeps paying for. A `null` there should
434
+ * mean `204`, and on this route it did not.
435
+ */
436
+ export const invokeOrServiceResponse = z.union([invokeResponse, serviceCallResponse]);
437
+ export const publishRequest = z.object({
438
+ message: z.record(z.string(), z.unknown()).meta({
439
+ description: 'The values to publish, keyed by the **parameter names** the publisher declares — the same flat form an invoke takes for `params`. They are checked against the declared bounds in the cloud before anything reaches the robot, and a slug another caller is still holding is refused with the remaining wait.',
440
+ }),
441
+ });
442
+ /**
443
+ * The **most recent** job on a slug — running or already finished — or null
444
+ * only when nothing has ever run there.
445
+ *
446
+ * It said "the job currently running" until W4's review, and that quietly
447
+ * made §11.3's first sentence false. The spec offers two equal ways to
448
+ * observe a slug — *"Polling (REST) oder Subscription (Realtime)"* — but a
449
+ * route that forgets a job the moment it settles lets a poller see only
450
+ * `running`, then `null`. Succeeded, failed, cancelled, `lost` and
451
+ * never-invoked all become the same answer, so §6.1's promise that a lost
452
+ * job is *said out loud* held for subscribers and silently did not hold for
453
+ * anyone polling. It is also the recovery `command_outcome_unknown` points
454
+ * a caller to.
455
+ *
456
+ * Read `job.state` to tell a live job from a finished one; that is what the
457
+ * field is for.
458
+ */
459
+ export const jobResponse = z.object({
460
+ job: job.nullable().meta({
461
+ description: 'The **most recent** job on this slug, running or already finished, and `null` only when nothing has ever run there. Read `state` to tell a live job from a settled one: a route that forgot a job the moment it settled would let a poller see `running` and then nothing.',
462
+ }),
463
+ });
464
+ /**
465
+ * Every job the platform currently believes this robot has — `GET
466
+ * /api/robots/:id/jobs` (W6b).
467
+ *
468
+ * `jobResponse` answers "what is on this slug", which requires knowing the
469
+ * slug first. That was enough while a job could only exist on a slug the
470
+ * published configuration named. W6b breaks that assumption twice: a
471
+ * reconnecting bridge can name a job the cloud has **no row for** and the
472
+ * cloud adopts it, and a configuration change can leave a job on a slug the
473
+ * document no longer contains. Both are jobs nobody can ask about, because
474
+ * asking requires already knowing what to ask for.
475
+ *
476
+ * So this route exists to answer the question the per-slug route cannot: not
477
+ * "is something running here", but "what is this robot doing". A restarted
478
+ * cloud that has just reconciled a robot's `hello.active_jobs` has exactly
479
+ * this list and, until now, no way to say it out loud.
480
+ *
481
+ * The array is ordered newest first and is **never null**: a robot doing
482
+ * nothing answers `{ jobs: [] }`. "Nothing is running" and "we did not look"
483
+ * are different facts, and a nullable list would merge them — the same
484
+ * distinction `robotDeletionSummary` was made all-required for.
485
+ *
486
+ * **At most one entry per slug: the current job there, exactly what
487
+ * `jobResponse` would answer for that slug.** This is not a history endpoint
488
+ * and must not become one. The first implementation returned every job the
489
+ * registry still held — six rows and four complete Fibonacci results after a
490
+ * few minutes of gate traffic, and unbounded in both count and payload for a
491
+ * robot that has been working all day. The list would have grown until a
492
+ * console page carried a robot's entire past, and the one thing it exists to
493
+ * answer — *what is this robot doing* — would have been the first line of a
494
+ * scroll.
495
+ *
496
+ * A settled job stays visible as its slug's current entry until something
497
+ * else runs there, which is what makes a job that just failed still findable.
498
+ * Read `state` to tell a live one from a finished one, exactly as with
499
+ * `jobResponse`.
500
+ */
501
+ /* ------------------------------------------------------------------ W6c --
502
+ * Identity, rewritten by the 2026-08-29 org-central redesign (D1/D2/D6).
503
+ * Written down here for the same reason the W4 command routes were: **a body
504
+ * schema does not imply a path**, and three consumers were about to derive
505
+ * nine paths independently from one implementation.
506
+ *
507
+ * **Two identity spaces, two prefixes** (2026-09-05 app-user-auth, D1). The
508
+ * `/api/org/` vs `/api/end-users/` split this table once insisted on, and the
509
+ * one pool that replaced it, are both gone. `/api/org/users` is the **team**:
510
+ * Fleetless users, console access, a tier each. An app's users live under
511
+ * `/api/apps/:id/users` and authenticate through `/api/client/`, and nothing
512
+ * joins the two — a credential from one never authenticates the other, and the
513
+ * same address in both is two unrelated accounts.
514
+ *
515
+ * **`src/routes.ts` is the manifest of record, and this table was not.** Every
516
+ * route, its schemas, its `errors` list and its guard are declared there, and
517
+ * the cloud's `route-manifest.test.ts` asserts set equality with the running
518
+ * server in both directions. This block kept a hand-written copy beside it and
519
+ * the copy drifted: it went on describing groups, assignments, a group's OIDC
520
+ * provider and the app OAuth flow after each was deleted. The table is removed
521
+ * rather than re-typed, because a second list is how the first one stops being
522
+ * read.
523
+ *
524
+ * What stays here is the *reasoning* the manifest has no field for. Each
525
+ * paragraph below is a decision, not a route listing.
526
+ *
527
+ * **Deleted with no successor, listed so a consumer looking for them finds the
528
+ * reason rather than a `404`:** every `/api/org/groups*` route, the per-user
529
+ * `assignments`, `move-group` and `usage` routes, `/api/org/federation`, the
530
+ * app's `group`, `group-usage`, `branding` and `oauth-clients` routes, the whole
531
+ * app OAuth sign-in flow (`/oauth/*` and `/login`), and `/api/client/grants*`.
532
+ * There are no compatibility aliases, because an alias here is how a deleted
533
+ * model survives in production while the contract says otherwise.
534
+ *
535
+ * **`POST /api/auth/password/reset` answers `202` for every well-formed
536
+ * address**, known or not. It is the one route where §3.3's silence about
537
+ * existence is not a preference but the entire point: any status, body or
538
+ * timing difference between the two cases is an account-enumeration oracle.
539
+ * Note *timing* — a route that only sends mail for a real address must not
540
+ * become measurably faster for an unknown one. Email is **globally unique**
541
+ * (Andre, 2026-08-29), so a bare address names at most one account and the
542
+ * route mails the one match, if any; the per-org detour the 2026-08-29
543
+ * redesign briefly took (multi-candidate verify on login, mail-every-match on
544
+ * reset) is retired, with no shape change. See `passwordResetRequest`.
545
+ *
546
+ * **Both surfaces get the password routes, mirrored.** Cluster D named the app
547
+ * user explicitly — *"an end user cannot change their own password, and there
548
+ * is no reset path"* — and a console user needs the same thing; the first
549
+ * version of this block gave the routes only one prefix, which would have
550
+ * shipped the wave's named item for the wrong principal. `passwordChangeRequest`
551
+ * is shared because the operation is identical; the **prefix** is what says
552
+ * which session is being spent, exactly as it does for `login`. The two *reset*
553
+ * requests are separate shapes rather than one, because the surfaces identify a
554
+ * person differently: a Fleetless user by a globally unique address, an app
555
+ * user by app **and** address.
556
+ *
557
+ * **A password change answers with fresh `sessionTokens`, not `204`.** The
558
+ * promise is that the session which made the change survives while every other
559
+ * one dies — and `passwordChangeRequest` carries nothing that identifies the
560
+ * caller's refresh family, so a route given only that shape cannot spare one.
561
+ * Re-issuing is the honest way to keep the promise: revoke everything, hand the
562
+ * caller a new pair. Anything else means the caller keeps working until their
563
+ * access token expires and is then silently logged out, which is
564
+ * indistinguishable from the change having failed (Nimbus-W6c).
565
+ *
566
+ * **Every link this wave mails must carry what the page needs to act on it.**
567
+ * Three things were mailed to pages that could not handle them — a reset link
568
+ * to the *request* page, an accept link to a `404`, a register confirmation to
569
+ * a redirect (Kassandra-W6c). Fixing the paths alone would have left the defect
570
+ * underneath: **both surfaces mailed the identical reset URL**, and the
571
+ * console's confirm page posts to the console route, so an app user's token
572
+ * sent there answers `token_spent` forever. A URL that does not say which
573
+ * surface minted it cannot be routed correctly by anything.
574
+ *
575
+ * So the link shapes are fixed here rather than in whichever repo builds them.
576
+ * **They moved to the auth portal** (auth-portal spec `2026-08-30`, D-A1): the
577
+ * console serves no credential page at all any more, and `{portal}` is the
578
+ * cloud's `AUTH_PUBLIC_URL` — `auth.fleetless.dev` where the deployment has
579
+ * that vhost, the cloud's own base where it does not, since the cloud renders
580
+ * these pages itself either way.
581
+ *
582
+ * | purpose | URL |
583
+ * |---|---|
584
+ * | password reset, Fleetless user | `{portal}/reset-password/{token}` |
585
+ * | team invitation | `{portal}/accept-invite/{token}` |
586
+ *
587
+ * **An app user's links are not in this table, and cannot be** (2026-09-05,
588
+ * D2/D5). Fleetless renders an app user no page, so there is no `{portal}` path
589
+ * to name: the link points into the **developer's own app**, at the template
590
+ * they configured (`appAuthConfig.invite_url`, `verify_url`, `reset_url`), with
591
+ * the token substituted for `{token}`. That is why those fields are validated
592
+ * as templates rather than as URLs, and why an app with none configured is
593
+ * refused a `send_mail` instead of being mailed a link to nowhere.
594
+ *
595
+ * The paragraph this replaces said an app-user reset *"is a feature to design,
596
+ * not a row to restore"*. It was designed; the answer was that the row belongs
597
+ * to the developer and not to this table.
598
+ *
599
+ * The strings themselves live in `cloud/src/portal-paths.ts`, read by the
600
+ * route that serves each page AND by the builder that mails it — one constant,
601
+ * because the defect this table records happened again after it was written:
602
+ * `buildAcceptUrl` mailed `{console}/accept-invite/{token}` while the console
603
+ * served `/invite/{token}`, and this table said a third thing. Nothing caught
604
+ * it because nothing shared a string.
605
+ *
606
+ * ## W7 — the asset store (§4.6)
607
+ *
608
+ * | route | who | role capability |
609
+ * |---|---|---|
610
+ * | `GET /api/robots/{id}/assets` | developer **or** end user | `assets`, end users only |
611
+ * | `GET /api/robots/{id}/assets/{assetId}` | developer **or** end user | `assets`, end users only |
612
+ * | `GET /api/robots/{id}/assets/missing?name=` | developer **or** end user | `assets`, end users only |
613
+ * | `GET /api/robots/{id}/urdf` | developer **or** end user | `assets`, end users only |
614
+ * | `POST /api/robots/{id}/assets/sync` | developer, **Owner** tier | — |
615
+ * | `GET /api/robots/{id}/assets/sync/{syncId}` | developer | — |
616
+ * | `POST /api/bridge/assets` | robot token, per sync | — |
617
+ *
618
+ * **The read routes are dual-mode, and the first version of this table said
619
+ * `developer` for all three — contradicting the sentence that followed it.**
620
+ * `assets` is an *app-role* capability (§3.3), and developers are not in any
621
+ * app's role system at all (§3.1/§3.4: two identity spaces, and a credential
622
+ * from one never authenticates the other). Enforced literally, an end user
623
+ * could never fetch a URDF — which is §4.6's entire "Clients: `GET .../urdf`"
624
+ * story, and the audience the asset store exists for.
625
+ *
626
+ * So: a developer reaches the robot because it belongs to their org; an end
627
+ * user reaches it when their role grants `assets`. Caught by Threepio-W7
628
+ * reading §3.3 against this table before anything was built on it — the second
629
+ * time in two waves that this one check has caught a delta placing a feature
630
+ * in the wrong identity space.
631
+ *
632
+ * **The rewritten mesh URIs in a served URDF are absolute, not
633
+ * root-relative.** A relative URL resolves against *the consumer's* origin,
634
+ * and the consumers here are apps on other domains — so `/api/robots/…` would
635
+ * 404 against the customer's own site. This is the same mistake as W5's
636
+ * `LIVEKIT_URL=localhost`, which was handed to a viewer's browser and cost an
637
+ * afternoon: **a URL we hand to somebody else's browser must never be relative
638
+ * to ours.** Raised by Data-W7 asking which it was rather than assuming.
639
+ *
640
+ * **There is no per-asset `DELETE`, and its absence is the design.** The first
641
+ * version of this table had one, for symmetry — which is not a reason. Assets
642
+ * are immutable and content-addressed, and the operation a developer actually
643
+ * performs is *the URDF changed, sync again*: a **re-sync reconciles**, so
644
+ * assets the new URDF no longer references stop belonging to that robot. One
645
+ * mechanism instead of two. Robot deletion is already covered by W6a's
646
+ * cascade.
647
+ *
648
+ * Left in, it would have been a route with no console, no SDK method and no
649
+ * gate step — register row 8's third instance, in the wave whose own contracts
650
+ * file warns about the first two by name. Caught by Eve-W7 asking why it was
651
+ * in her mission's route table but in neither her mission nor the gate.
652
+ *
653
+ * Reading is a role capability; **changing the store is Owner-tier**, matching
654
+ * W6c's reading of §3.1 — a sync spends the org's asset quota and a deletion
655
+ * breaks every app rendering that robot, so neither is a Member's to do.
656
+ *
657
+ * **`GET .../assets/missing` shipped undocumented for a whole wave and is the
658
+ * sole producer of `asset_missing` (W7a, Momus-W7 M5).** It never succeeds,
659
+ * and that is what it is for: when the served URDF is rewritten, a reference
660
+ * the store cannot answer has to be rewritten into *something*, and a URL that
661
+ * 404s `asset_missing` naming the reference is the only option that leaves the
662
+ * renderer's own error legible. The alternatives are worse — leaving the
663
+ * `package://` URI in place hands a browser a scheme it cannot fetch, and
664
+ * dropping the element silently deletes a limb.
665
+ *
666
+ * `?name=` is that reference, verbatim and URL-encoded: the same string
667
+ * `asset.name` stores and `urdfCompleteness.missing` reports, so what a
668
+ * developer sees in a failed network request matches what the completeness
669
+ * list told them to go fix. `asset_missing` is deliberately not `not_found`:
670
+ * "this robot does not exist" and "this mesh was never synced" send a
671
+ * developer to two different places.
672
+ *
673
+ * A route with a producer, a consumer and no entry in this table is how an
674
+ * error code ends up with no documented way to provoke it.
675
+ *
676
+ * `GET .../assets/{assetId}` answers **bytes**, not JSON, with
677
+ * `Cache-Control: private, immutable` and never `public`: a shared cache must
678
+ * not be invited to store a response to an authorized request. It is the one
679
+ * route in this API whose body is not an `apiError` on failure — a client
680
+ * fetching bytes must still be able to branch, so failures answer the normal
681
+ * envelope with `content-type: application/json`.
682
+ *
683
+ * `POST /api/bridge/assets` is the **first route authenticated by the robot
684
+ * credential over HTTP**. Everything the bridge does today goes over the
685
+ * WebSocket, so this is new surface, not a variation of something existing —
686
+ * and it accepts bodies far larger than any other route on the platform. It is
687
+ * where a rate limit and a size ceiling matter most, and where W6c's own rule
688
+ * applies: the refusal must precede the work, not follow it.
689
+ *
690
+ * The end-user links carry `app_identifier` because the page cannot act
691
+ * without it: `clientPasswordResetRequest` requires it, and an end user is
692
+ * identified by **app and address**, never address alone. The token alone is
693
+ * not enough, and a page that guesses the app is a page that guesses wrong.
694
+ *
695
+ * **This is a stopgap and should be named as one.** An app's users landing on
696
+ * *our console* to reset a password is wrong — the page belongs to the app,
697
+ * and an app has no configured base URL to send them to. Registered for W7;
698
+ * until then the console hosts both, and the URL carries the app so that
699
+ * moving it later is a redirect rather than a redesign.
700
+ *
701
+ * **`DELETE /api/org/members/:id` is not a row deletion.** Gate step 3 takes a
702
+ * token minted before the removal and uses it; if it still works, the feature
703
+ * is not built. `revokeSessionsForSubject` is already wired.
704
+ */
705
+ /**
706
+ * What a `rate_limited` refusal tells the caller (W6c).
707
+ *
708
+ * One number, and it is the only one that matters: **when to come back.** A
709
+ * limit that says "too many" without saying "in 800 ms" produces a client that
710
+ * retries immediately, which is the behaviour the limit exists to stop — so
711
+ * omitting it would make the refusal part of the attack.
712
+ *
713
+ * Deliberately **not** carrying the limit, the window, or how many attempts
714
+ * remain: those describe the defence to whoever is probing it, and none of
715
+ * them changes what an honest caller does.
716
+ */
717
+ export const rateLimitDetails = z.object({
718
+ retry_after_ms: z.number().int().nonnegative(),
719
+ });
720
+ /**
721
+ * Every job this robot's registry currently holds, **ordered newest first by
722
+ * `started_at`, with `seq` as the tiebreaker** (W7, register rows 2j and 2l).
723
+ *
724
+ * The field is named because the previous version of this comment claimed an
725
+ * order without saying what produced it, and the answer turned out to matter
726
+ * twice over:
727
+ *
728
+ * 1. **`started_at` alone is not a total order.** Two jobs minted in the same
729
+ * millisecond sorted against each other arbitrarily — differently on each
730
+ * query — so a reader could see one twice and the other not at all. `seq`
731
+ * is monotonic in mint order and settles it. Note its scope, which is in
732
+ * `job.seq`'s own comment: per cloud process, per run, because job state
733
+ * lives in memory and the counter restarts with the registry it orders.
734
+ * 2. **For an adopted job, `started_at` is adoption time, not the real
735
+ * start.** The cloud learns of it at `hello`, having never minted it, and
736
+ * has no other honest value to put there. So this list is newest-*known*
737
+ * first, and a job the robot has been running for an hour can sit above one
738
+ * started a minute ago. Stated rather than smoothed over: the console's own
739
+ * "Known running since" wording exists for the same reason, and a contract
740
+ * that quietly implies otherwise would send somebody to debug the sort.
741
+ */
742
+ export const robotJobsResponse = z.object({
743
+ jobs: z.array(job).meta({
744
+ description: 'At most one entry per slug — the current job there — ordered newest **known** first, and never `null`: a robot doing nothing answers an empty array. This is not a history endpoint. For an adopted job `started_at` is adoption time, so a job that has been running for an hour can sit above one started a minute ago.',
745
+ }),
746
+ });
747
+ /**
748
+ * Every slug of a robot that a role can be granted, **with its kind**.
749
+ *
750
+ * The roles matrix was built in W3 against the datapoint list, which was the
751
+ * only kind that existed. With four kinds it needs one list that names them,
752
+ * or the matrix silently cannot grant an action.
753
+ */
754
+ export const exposure = z.object({
755
+ slug,
756
+ kind: z.enum(['datapoint', 'action', 'service', 'publisher', 'camera']),
757
+ builtin: z.boolean(),
758
+ });
759
+ export const exposureListResponse = z.object({
760
+ exposures: z.array(exposure),
761
+ });
762
+ /* ------------------------------------------------------------------ W5 --
763
+ * Cameras (spec §10). Routes, written down as the W4 command routes are:
764
+ *
765
+ * | route | answers |
766
+ * |---|---|
767
+ * | `GET /api/robots/:id/cameras` | `cameraListResponse` |
768
+ * | `GET /api/robots/:id/cameras/:slug/snapshot` | the image bytes, plus the headers below |
769
+ * | `GET /api/robots/:id/cameras/:slug/snapshot/meta`| `snapshotMetaResponse` — age without the bytes |
770
+ * | `POST /api/robots/:id/cameras/:slug/live` | `liveSessionResponse` — takes a refcount hold |
771
+ * | `DELETE /api/robots/:id/cameras/:slug/live?session_id=` | 204 — releases **that** hold; without the parameter, all of this identity's holds on the camera |
772
+ */
773
+ /**
774
+ * The response headers a binary snapshot carries, named here so the cloud and
775
+ * every client agree without negotiating:
776
+ *
777
+ * - `Content-Type` — the image's mime, standard rather than invented.
778
+ * - `X-Fleetless-Age-Ms` — how old the frame is, **computed by the cloud**.
779
+ * - `X-Fleetless-Timestamp-Ms`— the bridge's capture time.
780
+ * - `X-Fleetless-Width` / `X-Fleetless-Height`.
781
+ *
782
+ * A client must take `age_ms` from the header and **never** recompute it as
783
+ * `Date.now() - timestamp_ms`: the cloud is the one clock that knows how long
784
+ * it has actually been holding the frame, and recomputing reintroduces the
785
+ * viewer's clock skew as a source of lying about freshness.
786
+ */
787
+ export const SNAPSHOT_HEADERS = {
788
+ ageMs: 'x-fleetless-age-ms',
789
+ timestampMs: 'x-fleetless-timestamp-ms',
790
+ width: 'x-fleetless-width',
791
+ height: 'x-fleetless-height',
792
+ };
793
+ /**
794
+ * The metadata an asset upload carries beside its raw body (W7).
795
+ *
796
+ * Here rather than as a convention documented on both sides, and the reason is
797
+ * a scar. W5 shipped `x-fleetless-*` headers the CORS policy did not expose,
798
+ * so `age_ms` was `null` in **every** browser while the SDK documented `null`
799
+ * as "nothing captured yet" — a fresh frame reporting as no snapshot at all,
800
+ * invisible to three test suites because none of them was a browser. And W6b
801
+ * found the general form: three repos agreeing with each other about a payload
802
+ * none of them exchanged, each right in its own tests.
803
+ *
804
+ * **A string shared by two repos and defined in both is a string that drifts.**
805
+ * A zod schema cannot validate a header, which is an argument for writing the
806
+ * names down once, not an argument for writing them down twice.
807
+ *
808
+ * `name` is the `package://` URI verbatim for a mesh — the same string
809
+ * `asset.name` stores, and the same one `urdfCompleteness.missing` reports, so
810
+ * a failed upload and a missing mesh can be matched by eye.
811
+ */
812
+ /**
813
+ * **`name` travels percent-encoded, and that is a fix rather than a
814
+ * convention** (W7a review, André's decision to fix rather than defer).
815
+ *
816
+ * HTTP header values are latin-1 (`http.client` in Python, and the same is
817
+ * true on the other side). So a texture called `textures/日本語.png` raised a
818
+ * `UnicodeEncodeError` **inside `urllib`** — a `ValueError`, caught by neither
819
+ * `HTTPError` nor `URLError` — which propagated to the sync's broad handler
820
+ * and marked **everything still remaining** as failed. One non-ASCII filename
821
+ * cost a developer every mesh after it in that sync, with no cause on the
822
+ * wire. R6 made it ordinary rather than exotic: `.dae` internal names come
823
+ * from 3D-authoring tools, where non-ASCII is Tuesday.
824
+ *
825
+ * The encoding is not invented here. **`GET .../assets/missing?name=` already
826
+ * carries this exact string percent-encoded**, because a query parameter is
827
+ * percent-encoded by definition — same value, same wire, question already
828
+ * answered.
829
+ *
830
+ * **It is a SECOND header, and that is the whole design rather than a
831
+ * detail.** The first version overloaded `name` itself: the producer would
832
+ * encode, the store would `decodeURIComponent`. That decodes identically for
833
+ * every name without a `%`, so an **older bridge and a newer cloud agree by
834
+ * luck** — right up until a name contains `%2f`, which the store would then
835
+ * silently turn into a `/`. A wire change whose breakage is invisible in the
836
+ * common case and silent in the uncommon one is the worst of both (Argus-W7a,
837
+ * reading the contract rather than the code).
838
+ *
839
+ * So `name` keeps meaning exactly what it always meant, and `nameEncoded`
840
+ * carries the percent-encoded UTF-8 form. **The store prefers `nameEncoded`
841
+ * when present and uses `name` otherwise**, so:
842
+ *
843
+ * - an older bridge sends only `name` and behaves exactly as before;
844
+ * - a newer bridge sends both, and a name it cannot express in latin-1 travels
845
+ * intact for the first time;
846
+ * - no value is ever ambiguous about which encoding it is in.
847
+ *
848
+ * A producer that can send `nameEncoded` should send both, so a store older
849
+ * than this contract keeps working too. Agreement by construction, not by the
850
+ * absence of a `%`.
851
+ */
852
+ export const ASSET_UPLOAD_HEADERS = {
853
+ kind: 'x-fleetless-asset-kind',
854
+ name: 'x-fleetless-asset-name',
855
+ nameEncoded: 'x-fleetless-asset-name-encoded',
856
+ syncId: 'x-fleetless-sync-id',
857
+ /**
858
+ * **Die angekündigte Größe, und sie ist der Grund, warum `asset_too_large`
859
+ * überhaupt entstehen kann (W9b, DEF-116).**
860
+ *
861
+ * Fastifys `bodyLimit` greift im Content-Type-Parser, also **vor** dem
862
+ * Handler — eine zu große Datei bekam damit ein blankes `413 bad_request`
863
+ * ohne `limit_bytes` und ohne `size_bytes`, und der strukturierte Fehlercode,
864
+ * den `assetTooLargeDetails` beschreibt, hatte schlicht keinen erreichbaren
865
+ * Erzeuger (Momus-W7, M1, an den echten Routenoptionen reproduziert).
866
+ *
867
+ * Mit einer angekündigten Größe im Kopf kann die Ablehnung dort entstehen,
868
+ * wo sie etwas sagen kann: bevor ein Byte gepuffert ist, mit beiden Zahlen.
869
+ * Und die Bridge erfährt ihre Grenze, ohne 194 MB zu lesen, um sie zu
870
+ * entdecken — was am 2026-08-18 auf rx1 genau so ausging (DEF-148).
871
+ *
872
+ * Der Kopf ist eine **Ankündigung, kein Beweis**: Ein Absender kann lügen.
873
+ * Der Deckel gilt weiterhin auch am Körper — dies ersetzt die Durchsetzung
874
+ * nicht, es macht die Absage nur beantwortbar.
875
+ */
876
+ size: 'x-fleetless-asset-size',
877
+ };
878
+ export const cameraDescriptor = z.object({
879
+ slug: slug.meta({ description: 'The name a client addresses this camera by.' }),
880
+ width: z.number().int().positive().meta({
881
+ description: 'Frame width in pixels, as the published configuration declares it.',
882
+ }),
883
+ height: z.number().int().positive().meta({
884
+ description: 'Frame height in pixels, as the published configuration declares it.',
885
+ }),
886
+ fps: z.number().int().positive().meta({
887
+ description: 'How many frames per second the camera is configured to publish while somebody is watching live.',
888
+ }),
889
+ /**
890
+ * Seconds, as the document spells it, reusing `snapshotIntervalSeconds` so
891
+ * the 1–3600 bound is written once. It was `snapshot_interval_ms` after the
892
+ * document moved to seconds, which left the cloud converting the unit on
893
+ * this descriptor and not on `datapointDescriptor` beside it — the same
894
+ * drift `rateThrottleHz` was extracted to stop.
895
+ */
896
+ snapshot_interval_seconds: snapshotIntervalSeconds.meta({
897
+ description: 'How often a still frame is captured for the cheap snapshot reads, in seconds, between `1` and `3600`. Independent of `fps`, which is about live video.',
898
+ }),
899
+ });
900
+ export const cameraListResponse = z.object({
901
+ cameras: z.array(cameraDescriptor).meta({
902
+ description: 'Every camera the published configuration exposes on this robot **and** the caller\'s role grants. A developer sees all of them; an end user sees what their role allows.',
903
+ }),
904
+ });
905
+ /**
906
+ * What a viewer needs to join, and **what it costs them to hold**.
907
+ *
908
+ * `POST` takes a refcount hold and `DELETE` releases it; the first hold
909
+ * starts the robot publishing and the last release stops it (§10). A client
910
+ * that forgets to release keeps a robot streaming to nobody, so the SDK hands
911
+ * back a `release()` rather than a bare token.
912
+ *
913
+ * **`expires_at` is a join deadline, not a session backstop.** A LiveKit
914
+ * token is checked when a participant connects and not again afterwards, so a
915
+ * viewer who has already joined keeps receiving video straight past this
916
+ * moment. Do not design cleanup around it. What actually ends a session is
917
+ * `release()` together with disconnecting the room, the cloud reconciling the
918
+ * hold away against LiveKit's real participants, or a revocation kicking the
919
+ * participant out. This comment previously claimed the opposite and the SDK
920
+ * inherited the claim from here — a developer reading it would reasonably
921
+ * have skipped cleanup on purpose.
922
+ */
923
+ export const liveSessionResponse = z.object({
924
+ /**
925
+ * This viewer's hold, and the **only** thing `DELETE` should be given
926
+ * (W6b).
927
+ *
928
+ * A hold was addressed by `{identity, robot, slug}` and nothing else, so
929
+ * two tabs of one logged-in user were one hold as far as the refcount could
930
+ * see. Closing either tab released it: the second tab kept its LiveKit
931
+ * connection — the token is checked at join and never again — and went on
932
+ * rendering a video that the robot had already stopped producing. The
933
+ * viewer sees a frozen picture, not an ended session, which is the failure
934
+ * this project rejects everywhere else.
935
+ *
936
+ * `DELETE` without a session id keeps today's meaning — *release my holds
937
+ * on this camera* — because an SDK that has lost its id, or a client that
938
+ * is going away entirely, still needs a way to let go. It is the blunt
939
+ * form, and it is the one that strands other tabs; new callers pass the id.
940
+ */
941
+ session_id: z.uuid().meta({
942
+ description: 'This viewer\'s hold, and the only thing a release should be given. Two tabs of one logged-in user are two holds; releasing without an id lets go of both and leaves the other tab rendering a stream the robot has already stopped producing.',
943
+ }),
944
+ url: z.string().min(1).meta({
945
+ description: 'The LiveKit server to connect to, as a WebSocket URL.',
946
+ }),
947
+ room: z.string().min(1).meta({
948
+ description: 'The LiveKit room carrying this camera. Every viewer of one camera on one robot joins the same room, which is what makes the refcount hold meaningful.',
949
+ }),
950
+ token: z.string().min(1).meta({
951
+ description: 'The LiveKit access token to join `room` with. It is checked when the participant connects and **not again afterwards** — which is not the same as irrevocable: the cloud can still disconnect a participant after the fact, and does when membership, a role or a key changes.',
952
+ }),
953
+ expires_at: z.iso.datetime().meta({
954
+ description: 'The deadline for **joining**, as an ISO 8601 timestamp — not a session backstop. A viewer who has already joined keeps receiving video past this moment, so cleanup belongs in an explicit release, never in a timer built on this value.',
955
+ }),
956
+ });
957
+ /**
958
+ * The snapshot read **without the bytes**.
959
+ *
960
+ * A viewer polling at the camera's interval otherwise re-downloads a whole
961
+ * image to discover whether a new one exists. This is the cheap question —
962
+ * *how old is what you have?* — so a client can fetch pixels only when the
963
+ * timestamp actually moved. It matters most on the console's snapshot view,
964
+ * which polls continuously while a tab is open.
965
+ *
966
+ * `age_ms` is not a convenience: a cached frame served without its age is
967
+ * indistinguishable from a live one, and §10 makes snapshots deliberately
968
+ * cheap and therefore deliberately old. `null` values mean nothing has been
969
+ * captured yet — which is an answer, not an error.
970
+ */
971
+ export const snapshotMetaResponse = z.object({
972
+ slug: slug.meta({ description: 'The camera this snapshot belongs to.' }),
973
+ timestamp_ms: z.number().int().nonnegative().nullable().meta({
974
+ description: 'When the stored frame was captured, as a unix timestamp in milliseconds. `null` means nothing has been captured yet, which is an answer rather than an error.',
975
+ }),
976
+ age_ms: z.number().int().nonnegative().nullable().meta({
977
+ description: 'How old the stored frame is right now, in milliseconds; `null` when there is none. Snapshots are deliberately cheap and therefore deliberately old, and a cached frame served without its age is indistinguishable from a live one.',
978
+ }),
979
+ width: z.number().int().positive().nullable().meta({
980
+ description: 'Width of the stored frame in pixels, or `null` when nothing has been captured yet.',
981
+ }),
982
+ height: z.number().int().positive().nullable().meta({
983
+ description: 'Height of the stored frame in pixels, or `null` when nothing has been captured yet.',
984
+ }),
985
+ mime: z.string().nullable().meta({
986
+ description: 'The media type of the stored frame, such as `image/jpeg`, or `null` when nothing has been captured yet.',
987
+ }),
988
+ });
989
+ // ---------------------------------------------------------------------------
990
+ // W6 — retention, history and org quotas (§8, §12.4)
991
+ // ---------------------------------------------------------------------------
992
+ /**
993
+ * **Both history shapes answer the same boundary the same way: `[from, to)`
994
+ * (W9d, DEF-062 — decision pre-made at the W6 boundary so no wave
995
+ * re-litigates it).**
996
+ *
997
+ * They did not. `samples` was inclusive of `to`, `buckets` exclusive — same
998
+ * range, same data, opposite answers for a point landing exactly on `to`, and
999
+ * the buckets answer rendered as a gap tooltipped *"empty — no samples"*.
1000
+ * `sdk/README.md` documented the inclusive notation for the half-open path,
1001
+ * so it was wrong for one of the two whichever way you read it.
1002
+ *
1003
+ * Half-open wins because it is the only rule under which **adjacent windows
1004
+ * tile without overlap**: `[0,10)` then `[10,20)` covers every instant once.
1005
+ * With an inclusive upper bound a sample at exactly `10` belongs to both
1006
+ * windows, and any consumer summing them counts it twice.
1007
+ *
1008
+ * This is a statement about behaviour, not a field — nothing in the shapes
1009
+ * below can enforce it. It is written here because this is the one place both
1010
+ * shapes are defined together, and the cloud's `history-store` and the SDK's
1011
+ * README are the two places that have to agree with it.
1012
+ */
1013
+ /**
1014
+ * A history query (§8). `from`/`to` accept **either** a relative expression
1015
+ * (`now-30s`, `now-5m`, `now-1h`) **or** absolute unix milliseconds, because
1016
+ * a chart asks the first way and a report asks the second, and making a
1017
+ * client convert is making it guess our clock.
1018
+ *
1019
+ * `window` without `agg` is meaningless and `agg` without `window` is
1020
+ * ambiguous — both are refused rather than assigned a default, since a
1021
+ * silently chosen aggregation is a chart that lies quietly.
1022
+ */
1023
+ export const historyQuery = z.object({
1024
+ from: z.string().min(1).max(32).meta({
1025
+ description: 'The start of the window: either a relative expression — `now-30s`, `now-5m`, `now-1h` — or absolute unix milliseconds. A chart asks the first way and a report asks the second, and making a client convert would be making it guess our clock.',
1026
+ }),
1027
+ to: z.string().min(1).max(32).optional().meta({
1028
+ description: 'The end of the window, in the same two spellings as `from`; absent means now. The window is half-open, `[from, to)`, so a sample landing exactly on `to` belongs to the next window and adjacent windows tile without double-counting.',
1029
+ }),
1030
+ window: z.string().min(2).max(16).optional().meta({
1031
+ description: 'The bucket width, such as `10s` or `1m`. Absent means raw samples. It is meaningless without `agg`, and the pair is refused apart rather than defaulted — a silently chosen aggregation is a chart that lies quietly.',
1032
+ }),
1033
+ agg: z.enum(['min', 'max', 'avg']).optional().meta({
1034
+ description: 'How each bucket reduces the samples inside it. Valid only together with `window`.',
1035
+ }),
1036
+ field: z.string().min(1).max(128).optional().meta({
1037
+ description: 'A dotted path to a numeric field inside an object value, such as `pose.x`. Without it the datapoint\'s value is used whole, which only works when it is already a number.',
1038
+ }),
1039
+ /**
1040
+ * **A union whose input branch IS the wire, not a coercion (W9d, DEF-059).**
1041
+ *
1042
+ * This was `z.coerce.number()`, for a good reason that stayed true: the
1043
+ * schema describes a **query string**, where every value arrives as text,
1044
+ * and a bare `z.number()` would make each route coerce by hand. What was
1045
+ * measured afterwards is that a coercion cannot be *published*: zod renders
1046
+ * a coercion's **result** in either `io` mode, so `io: 'input'` and
1047
+ * `io: 'output'` both emit `{"type":"integer"}` — an artifact describing a
1048
+ * shape a query string can never carry. Anyone validating a real request
1049
+ * against it rejects every one that sets `limit`.
1050
+ *
1051
+ * That is a **different** defect from the `.default()` class, which
1052
+ * `io: 'input'` genuinely does fix; `export-schemas.ts` once claimed one
1053
+ * remedy for both and has been corrected.
1054
+ *
1055
+ * A union states both truths honestly: the wire carries a numeric string,
1056
+ * a programmatic caller may pass a number, and the artifact can render the
1057
+ * input branch because there is one to render.
1058
+ *
1059
+ * **What the artifact no longer says, named here rather than left silent.**
1060
+ * The `1..10000` bound lives in the `.pipe()`, which is the *output* half, so
1061
+ * no input-mode artifact can express it as a constraint: the published shape
1062
+ * is `^\d{1,5}$` or a bare integer, and five digits is a weak echo of the
1063
+ * real ceiling. That is honest about the wire — the bound is enforced after
1064
+ * parsing, not by the shape of the text — but it is a **reduction**, and an
1065
+ * artifact that stops naming a bound reads as if there were none.
1066
+ *
1067
+ * So both branches carry the number in a `.describe()` (Nimbus-W9d's
1068
+ * proposal). It is **not** a constraint and nothing validates against it; it
1069
+ * means a generator, or a person reading only the published schema, sees the
1070
+ * actual ceiling instead of nothing. The gap is narrowed and named rather
1071
+ * than closed.
1072
+ */
1073
+ limit: z
1074
+ .union([
1075
+ z
1076
+ .string()
1077
+ .regex(/^\d{1,5}$/)
1078
+ // **The description carries the number the shape cannot** (Nimbus-W9d's
1079
+ // proposal). Five digits is the regex's bound, not the contract's; the
1080
+ // real ceiling lives in the `.pipe()` below and therefore cannot appear
1081
+ // in an input-mode artifact. This does not close that gap and does not
1082
+ // claim to — it means a generator, or a person reading only the
1083
+ // published schema, sees the actual number instead of none at all.
1084
+ .describe('Positive integer, 1-10000. The pattern only bounds digit count; the real ceiling is enforced after parsing.'),
1085
+ // The same sentence on the numeric branch, for the same reason and one
1086
+ // that is arguably stronger: without it the artifact publishes the full
1087
+ // safe-integer range, which reads as *nine quadrillion is fine*.
1088
+ z.number().int().describe('Positive integer, 1-10000. The ceiling is enforced after parsing, not by this type.'),
1089
+ ])
1090
+ .transform((v) => Number(v))
1091
+ .pipe(z.number().int().positive().max(10_000))
1092
+ .optional()
1093
+ .meta({
1094
+ description: 'The most samples or buckets to return, from `1` to `10000`. It arrives as text on the query string, so both a numeric string and a number are accepted; the ceiling is enforced after parsing rather than by the published shape.',
1095
+ }),
1096
+ });
1097
+ /**
1098
+ * Raw samples. `timestamp_ms` is the **bridge's capture time** (§6.3) — the
1099
+ * same instant the live value carried, so a recorded point and a live one can
1100
+ * be placed on one axis without apology.
1101
+ *
1102
+ * `truncated` says the response was cut short. A short array that does not
1103
+ * admit it is indistinguishable from a quiet period, and the two lead a
1104
+ * developer to opposite conclusions.
1105
+ */
1106
+ export const historySamplesResponse = z.object({
1107
+ slug: slug.meta({ description: 'The datapoint these samples belong to.' }),
1108
+ kind: z.literal('samples').meta({
1109
+ description: 'Says this is the raw-sample shape, which the query asked for by omitting `window`. A client reads this rather than inspecting which fields arrived.',
1110
+ }),
1111
+ samples: z.array(z.object({
1112
+ timestamp_ms: z.number().int().nonnegative().meta({
1113
+ description: 'When the sample was captured, as a unix timestamp in milliseconds. It is the **bridge\'s capture time** — the same instant the live value carried, so a recorded point and a live one sit on one axis without apology.',
1114
+ }),
1115
+ value: z.unknown().meta({
1116
+ description: 'The value as it was stored, shaped by the datapoint. A `field` in the query narrows a message down to one number; without one the whole stored value comes back.',
1117
+ }),
1118
+ })).meta({
1119
+ description: 'The samples in the queried window, oldest first. The window is half-open, `[from, to)`, so a sample landing exactly on `to` belongs to the next window.',
1120
+ }),
1121
+ truncated: z.boolean().meta({
1122
+ description: 'Whether the response was cut short. A short array that does not admit it is indistinguishable from a quiet period, and the two lead a developer to opposite conclusions.',
1123
+ }),
1124
+ /**
1125
+ * Why it was cut, `null` when it was not — because the two causes have
1126
+ * **different remedies** and a single boolean cannot tell them apart:
1127
+ *
1128
+ * `'limit'` too many rows. Raise `limit` (up to 10 000).
1129
+ * `'bytes'` the rows are large. Raising `limit` will not help — narrow
1130
+ * the range, or name a numeric `field` so whole messages are
1131
+ * not carried.
1132
+ *
1133
+ * A caller cannot derive this: comparing `samples.length` against `limit`
1134
+ * only works if they sent one, and the server's default is not in the
1135
+ * response. So without this field, "raise the limit" is the natural next
1136
+ * move in both cases, and in the second it changes nothing.
1137
+ *
1138
+ * Nullable rather than optional on purpose: `.default()` publishes as
1139
+ * `required` in the JSON Schema artifacts, which is the contradiction this
1140
+ * project has now hit five times.
1141
+ */
1142
+ truncated_by: z.enum(['limit', 'bytes']).nullable().meta({
1143
+ description: 'Why it was cut, and `null` when it was not — the two causes have **different remedies** and one boolean cannot tell them apart. `limit` means too many rows, so raising `limit` helps. `bytes` means the rows are large, so raising `limit` changes nothing: narrow the range, or name a numeric `field` so whole messages are not carried.',
1144
+ }),
1145
+ });
1146
+ /**
1147
+ * Aggregated buckets — a **separate shape**, not the samples shape with nulls
1148
+ * in it, so a client knows by type what it received rather than by
1149
+ * inspection.
1150
+ *
1151
+ * `sample_count` exists because an empty bucket and a bucket whose average is
1152
+ * zero are different facts. W5 established at some cost what happens when two
1153
+ * facts share one representation, and a chart is the easiest place in this
1154
+ * product to draw a gap as a line.
1155
+ */
1156
+ export const historyBucketsResponse = z.object({
1157
+ slug: slug.meta({ description: 'The datapoint these buckets summarise.' }),
1158
+ kind: z.literal('buckets').meta({
1159
+ description: 'Says this is the aggregated shape, which the query asked for by naming a `window`. A separate shape rather than the sample shape with nulls in it, so a client knows by type what it received rather than by inspection.',
1160
+ }),
1161
+ window_ms: z.number().int().positive().meta({
1162
+ description: 'The bucket width actually used, in milliseconds — the query\'s `window` resolved to a number, so a rendered chart can say what it is drawing without re-parsing the expression it sent.',
1163
+ }),
1164
+ agg: z.enum(['min', 'max', 'avg']).meta({
1165
+ description: 'How each bucket reduced the samples inside it, echoed back from the query.',
1166
+ }),
1167
+ buckets: z.array(z.object({
1168
+ bucket_start_ms: z.number().int().nonnegative().meta({
1169
+ description: 'The instant this bucket opens, as a unix timestamp in milliseconds. Buckets are half-open and `window_ms` wide, so this one covers up to but not including `bucket_start_ms + window_ms`.',
1170
+ }),
1171
+ /**
1172
+ * The aggregate over this bucket's **numeric** samples — or `null` when
1173
+ * none of them were numeric, which is **not** the same as the bucket
1174
+ * being empty. `sample_count` is the field that separates those facts:
1175
+ *
1176
+ * value: null, sample_count: 0 nothing was recorded — a gap
1177
+ * value: null, sample_count: 3 three samples, none of them numeric
1178
+ * value: 0, sample_count: 3 three samples, and the average is zero
1179
+ *
1180
+ * A chart must draw the first as a break in the line and must **not**
1181
+ * draw the second as one: data exists there, it simply has no height.
1182
+ *
1183
+ * This sentence previously read "`null` only ever means 'no samples in
1184
+ * this bucket'", and the implementation counted numeric contributors,
1185
+ * so the second row above was indistinguishable from the first and the
1186
+ * console rendered "empty — no samples" over live data.
1187
+ */
1188
+ value: z.number().nullable().meta({
1189
+ description: 'The aggregate over this bucket\'s **numeric** samples, or `null` when none of them were numeric — which is **not** the same as the bucket being empty. `sample_count` separates those: `null` with a count of `0` is a gap a chart should draw as a break, `null` with a count above `0` is data that simply has no height.',
1190
+ }),
1191
+ /**
1192
+ * Every sample that landed in this bucket and inside the queried range,
1193
+ * whether or not it contributed to `value` — which is the point of the
1194
+ * field, since only a count of *all* samples can prove a bucket empty
1195
+ * rather than merely unplottable.
1196
+ *
1197
+ * Two consequences, stated rather than left to be discovered:
1198
+ *
1199
+ * - `value` is not an average *of* `sample_count` samples when a
1200
+ * datapoint's values are mixed, so **`value * sample_count` is not a
1201
+ * sum**.
1202
+ * - On a first or last bucket the count reflects the **range**, not the
1203
+ * bucket: an edge bucket can begin before `from` or extend past `to`,
1204
+ * and only in-range samples are counted. A low edge count is a
1205
+ * boundary effect, not a quiet period.
1206
+ */
1207
+ sample_count: z.number().int().nonnegative().meta({
1208
+ description: 'Every sample that landed in this bucket and inside the queried range, whether or not it contributed to `value` — only a count of *all* samples can prove a bucket empty rather than merely unplottable. Two consequences: `value * sample_count` is **not** a sum, and on a first or last bucket the count reflects the range rather than the bucket, so a low edge count is a boundary effect and not a quiet period.',
1209
+ }),
1210
+ })).meta({
1211
+ description: 'The buckets covering the queried window, oldest first. A range and window that would produce more than `limit` buckets is refused before the query runs, because this shape carries no `truncated` field and a refusal is then the only honest answer.',
1212
+ }),
1213
+ });
1214
+ /**
1215
+ * **What `GET /api/robots/:id/datapoints/:slug/history` answers, which is one
1216
+ * of two shapes.**
1217
+ *
1218
+ * **The query decides, and only the query**: without `window` it is a
1219
+ * `historySamplesResponse`, with one it is a `historyBucketsResponse`.
1220
+ * `window` and `agg` must be given together or not at all — one without the
1221
+ * other is refused rather than defaulted, since a silently chosen aggregation
1222
+ * is a chart that lies quietly.
1223
+ *
1224
+ * Told apart by `kind`, which is `'samples'` or `'buckets'`, so a client
1225
+ * branches on a field rather than on which other fields happen to be present.
1226
+ * The two are deliberately not one shape with nullable halves: an aggregate
1227
+ * and a raw reading answer different questions, and `sample_count` exists on
1228
+ * only one of them.
1229
+ *
1230
+ * **This union exists so the route can name a response at all.** The entry
1231
+ * carried `response: null` while the handler demonstrably answers something,
1232
+ * which reads in the generated reference as *this route returns nothing*.
1233
+ */
1234
+ export const historyResponse = z.union([historySamplesResponse, historyBucketsResponse]);
1235
+ /**
1236
+ * W6a — deletion, and the one channel that reports health.
1237
+ *
1238
+ * | Route | Body | Answer |
1239
+ * |---|---|---|
1240
+ * | `DELETE /api/robots/:id` | — | `204`. `?force=true` to proceed while a live session is open; without it, `409 robot_in_use` |
1241
+ * | `GET /api/robots/:id/deletion-preview` | — | `robotDeletionSummary` — the same shape the audit event carries |
1242
+ * | `GET /api/org/health` | — | `resourceHealthListResponse`; `?robot_id=` narrows it to one robot |
1243
+ *
1244
+ * Plus `resourceHealthEvent`, pushed on the **developer** realtime socket
1245
+ * and scoped to the org — not to a subscription, because its job is to reach
1246
+ * somebody who is *not* looking at the thing that broke.
1247
+ *
1248
+ * Two of these paths are worth stating rather than inferring:
1249
+ *
1250
+ * **The preview exists because a confirmation must be able to name what it
1251
+ * destroys.** `DELETE` answers `204` with no body, so the counts only ever
1252
+ * appear on the audit event — written *after* the irreversible click. A
1253
+ * dialog built on that can say nothing better than "are you sure?". The
1254
+ * preview returns the *same shape* as the audit record on purpose: the
1255
+ * warning and the receipt then agree by construction, and a disagreement
1256
+ * between them is a real finding rather than two estimates drifting.
1257
+ *
1258
+ * **The snapshot and the event share the org's scope**, and the snapshot
1259
+ * takes an optional `robot_id` filter rather than living at a per-robot
1260
+ * path.
1261
+ *
1262
+ * The first version of this table said the opposite, with a justification
1263
+ * that sounded right and was incomplete: it reasoned only from a page that
1264
+ * has just opened one robot. But the console shows health on the **robot
1265
+ * list** too, and a per-robot path makes that N requests to render one
1266
+ * screen — while the event that must keep it fresh arrives org-wide anyway.
1267
+ * A snapshot and a channel that disagree about scope are not two halves of
1268
+ * one thing; they are two things that have to be reconciled by every
1269
+ * consumer, separately, forever.
1270
+ *
1271
+ * So: same scope, one route, and `?robot_id=` for the narrow question. The
1272
+ * cloud owner proposed this while unblocking the console, and was right.
1273
+ *
1274
+ * This table was missing from the first W6a delta, and a teammate had to ask
1275
+ * three separate people for the paths — which is how a route becomes a fact
1276
+ * that lives only in an inbox.
1277
+ */
1278
+ /**
1279
+ * What a `robot.deleted` audit event carries (W6a).
1280
+ *
1281
+ * A deletion record that says only *that* something was destroyed is a
1282
+ * receipt for an unknown amount. This names it: how many configured slugs,
1283
+ * how many stored samples, how many bytes that freed against the retention
1284
+ * quota, which cameras existed, how much attributed run history went with
1285
+ * it, and whether somebody was watching at the time. Those are the questions
1286
+ * asked afterwards, and afterwards is the one moment the data cannot be
1287
+ * consulted.
1288
+ */
1289
+ export const robotDeletionSummary = z.object({
1290
+ /**
1291
+ * Datapoints, actions, services and publishers in the **published**
1292
+ * configuration — what the robot was actually running. **Cameras are not
1293
+ * counted here**; they are the `cameras` array below.
1294
+ *
1295
+ * The split has to be stated because the summary carries both, and the
1296
+ * console renders them in one sentence: *"this deletes N published slugs …
1297
+ * and M cameras"*. With cameras inside `slug_count` that sentence counts
1298
+ * them twice, on the one screen whose whole justification is naming what an
1299
+ * irreversible click destroys (Momus, W6a review — the cloud summed all
1300
+ * five and the console then added the cameras again).
1301
+ *
1302
+ * A draft is destroyed too and is described by `had_unpublished_draft`
1303
+ * rather than by either of these: describing three things with two numbers
1304
+ * would make each of them mean something else.
1305
+ */
1306
+ slug_count: z.number().int().nonnegative(),
1307
+ sample_rows: z.number().int().nonnegative(),
1308
+ bytes_freed: z.number().int().nonnegative(),
1309
+ cameras: z.array(slug),
1310
+ /**
1311
+ * Assets destroyed with the robot (W7), and **`asset_bytes_freed` is what
1312
+ * this org actually gets back** — not the sum of the assets' sizes.
1313
+ *
1314
+ * Storage is content-addressed, so a mesh two robots share survives the
1315
+ * deletion of one of them and frees nothing. Reporting the total would tell
1316
+ * a developer they are about to recover 400 MB and hand back 4, on the one
1317
+ * screen whose entire justification is naming what an irreversible click
1318
+ * destroys. Same reasoning that keeps `cameras` out of `slug_count`: this
1319
+ * summary is read aloud to a human, and a number that is nearly right is
1320
+ * worse here than an absent one.
1321
+ *
1322
+ * `asset_count` is the plain count of the robot's asset rows, all of which
1323
+ * do go away.
1324
+ */
1325
+ asset_count: z.number().int().nonnegative(),
1326
+ asset_bytes_freed: z.number().int().nonnegative(),
1327
+ /**
1328
+ * How many rows of run history go with the robot — every recorded
1329
+ * invocation of one of its actions or services, up to
1330
+ * `JOB_RUN_RETENTION_DAYS`.
1331
+ *
1332
+ * Its own number, never folded into `slug_count`, for the same reason
1333
+ * `cameras` is not: `slug_count` counts *configuration* — what the robot
1334
+ * was set up to do — and this counts *what was actually done*, over as
1335
+ * much as 90 days. One robot with four slugs can carry forty thousand
1336
+ * runs, and a sentence that added them would describe two unrelated
1337
+ * magnitudes with one number on the one screen whose entire justification
1338
+ * is naming what an irreversible click destroys.
1339
+ *
1340
+ * It is also the only field here that names *people*: a run row carries
1341
+ * the `jobActor` who invoked it — a developer's or end user's email,
1342
+ * snapshotted at invoke time. So this deletion destroys attributed history
1343
+ * of who asked the machine to do what, which is a different kind of loss
1344
+ * from a count of sample rows and deserves to be said out loud rather than
1345
+ * inferred.
1346
+ *
1347
+ * **Bridge latency buckets are deliberately not counted here, and this is
1348
+ * the note saying so** rather than leaving the asymmetry to be
1349
+ * rediscovered as an omission. They are platform telemetry with a seven-day
1350
+ * life (`BRIDGE_LATENCY_RETENTION_DAYS`), produced by the cloud's own
1351
+ * pinging rather than by anything the developer did, counted against no
1352
+ * retention quota, and worth nothing to anybody after the robot is gone.
1353
+ * This summary is read aloud to a human deciding whether to click, and its
1354
+ * value comes from naming what the *developer* loses; a number for
1355
+ * telemetry they never asked for and cannot use would dilute exactly that.
1356
+ */
1357
+ job_run_count: z.number().int().nonnegative(),
1358
+ had_live_session: z.boolean(),
1359
+ /**
1360
+ * Whether an unpublished draft went with it — separately, because the
1361
+ * counts above deliberately do not include it and a record that silently
1362
+ * omitted the draft would be a receipt for less than was destroyed.
1363
+ *
1364
+ * `true` also covers the robot that was configured but never published:
1365
+ * there the counts are zero and this is the only field saying anything
1366
+ * was there at all.
1367
+ */
1368
+ had_unpublished_draft: z.boolean(),
1369
+ });
1370
+ /**
1371
+ * The query of `DELETE /api/robots/:id`.
1372
+ *
1373
+ * **`force=true` or nothing, and every other value is refused.** The handler
1374
+ * parses the query with this schema and answers `400 validation_error` on
1375
+ * anything else, so `?force=1` and `?force=TRUE` are neither forced nor
1376
+ * quietly un-forced. That is the whole point of the strictness: silently
1377
+ * false was the worst answer available, because a caller who believes they
1378
+ * authorised a cascade and did not then gets a `409` naming the very flag
1379
+ * they passed, and cannot tell which of the two happened.
1380
+ *
1381
+ * Declared as the literal string because it is the only value that does
1382
+ * anything — a `z.boolean()` here would describe a wire shape a query string
1383
+ * cannot carry, and a `z.string()` would document nothing. The MCP door takes
1384
+ * a real boolean and cannot express the ambiguity at all, so the two are one
1385
+ * policy in two vocabularies rather than two policies.
1386
+ */
1387
+ export const robotDeleteQuery = z
1388
+ .object({
1389
+ force: z.literal('true').optional().meta({
1390
+ description: 'Pass `true` to delete a robot that has a live session open; without it that is `409 robot_in_use`. **`true` and nothing else** — any other value is `400 validation_error`, reported against the field `force` with rule `invalid_value`, so a caller is never left believing they forced a deletion they did not. The deletion is a full cascade, which is why saying it is the whole decision.',
1391
+ }),
1392
+ })
1393
+ .meta({ description: 'The one optional parameter of `DELETE /api/robots/:id`, and it is the difference between a refusal and a cascade. It accepts the exact string `true`, or its own absence, and refuses everything else.' });
1394
+ /**
1395
+ * The seven health states, declared **once** (W6a review).
1396
+ *
1397
+ * `resourceHealthState` and `resourceHealthEvent` are the snapshot and the
1398
+ * push of the same thing, and they had the same seven values written out
1399
+ * twice, linked by nothing — the artifacts published two independent copies
1400
+ * with no `$ref`. They agreed only because whoever added `unknown` remembered
1401
+ * to add it in both places, on the wave's last contract commit.
1402
+ *
1403
+ * One concept rendering as two artifacts that nothing keeps in step is its
1404
+ * own class of artifact-versus-source defect, distinct from `.default()`
1405
+ * publishing as `required` and from `z.coerce`'s unrepresentable input.
1406
+ */
1407
+ export const RESOURCE_HEALTH_STATES = [
1408
+ 'ok',
1409
+ /** The host did not answer. Not the same as refusing the password. */
1410
+ 'unreachable',
1411
+ /** The host answered and rejected the credentials. */
1412
+ 'auth_failed',
1413
+ /** The stored password cannot be decrypted — see `credentialSummary.readable`. */
1414
+ 'unreadable_credential',
1415
+ /**
1416
+ * A camera names a credential that **does not exist** in this org — deleted,
1417
+ * mistyped, or belonging to somebody else (W6a review).
1418
+ *
1419
+ * Separate from `unreadable_credential` because that one asserts a
1420
+ * decryption that was attempted and failed, and here nothing was ever
1421
+ * encrypted: the developer is sent to a page where the credential is not
1422
+ * listed at all, to rotate something that is not there. And separate from
1423
+ * `unknown`, which means "the robot reported a failure we cannot classify"
1424
+ * — a different fact with a different fix.
1425
+ *
1426
+ * **Retiring with the credential store**, and not live behaviour to build
1427
+ * against. Its one producer was `cloud-config-frame.ts` tolerating an
1428
+ * unresolved `credentials_ref` at publish time; FL-002 deleted that field,
1429
+ * so nothing emits this today. It is kept only until the wave that removes
1430
+ * the store also removes these three credential states — `unreadable_credential`
1431
+ * and the `readable` fact on `credentialSummary` go the same way.
1432
+ */
1433
+ 'credential_missing',
1434
+ /** A configuration change stopped this stream, deliberately. */
1435
+ 'stopped_by_config_change',
1436
+ /** Publishing failed after the session was already granted. */
1437
+ 'publish_failed',
1438
+ /**
1439
+ * Something is wrong and this platform cannot say what.
1440
+ *
1441
+ * The alternative was worse. A bridge error code the mapping table does not
1442
+ * know had two possible fallbacks: report `ok`, which hides a real failure,
1443
+ * or fold it into `unreachable`, which **asserts a cause nobody
1444
+ * established** — sending a developer to check a network when the problem
1445
+ * may be a password. The map falls back here and logs the unmapped code
1446
+ * loudly, so the gap in the table is visible instead of confident.
1447
+ */
1448
+ 'unknown',
1449
+ ];
1450
+ /**
1451
+ * The health of one thing a developer configured, as the platform currently
1452
+ * sees it (W6a).
1453
+ *
1454
+ * This exists because four separate findings turned out to be one absence:
1455
+ * nothing carried the state of a camera, a source or a credential to a
1456
+ * developer who was not, at that exact moment, pressing a button. A publish
1457
+ * failure after the `201` never reached the viewer holding the token; a
1458
+ * source whose password was wrong failed at config-apply time with nobody
1459
+ * watching and stayed silent until someone pressed "Go live" days later; a
1460
+ * viewer could not learn *why* a stream ended, so the console had to offer
1461
+ * two possibilities and rank neither; and an undecryptable credential
1462
+ * reported as healthy.
1463
+ *
1464
+ * One shape, because four patches against four symptoms is how W5 nearly
1465
+ * wrote a failure report into `publishState` — a field the cloud writes and
1466
+ * reads in exactly one place, which would have been a dead end.
1467
+ *
1468
+ * `reason` is for a human and is **never** built from an exception message:
1469
+ * W6 found a camera password in a log through `log.exception`, and again in
1470
+ * `LiveStartError`'s message, which travels to the cloud on this very path.
1471
+ * Type names and fixed strings only.
1472
+ */
1473
+ export const resourceHealthState = z.object({
1474
+ robot_id: z.uuid(),
1475
+ kind: z.enum(['camera']),
1476
+ /** The camera slug, or the credential name. */
1477
+ ref: z.string().min(1).max(64),
1478
+ /**
1479
+ * **Which of two questions this entry answers (W9a, DEF-072).**
1480
+ *
1481
+ * `'source'` — can the source be read at all? (`unreachable`, `auth_failed`,
1482
+ * `unreadable_credential`, `missing_credential`, `ok`, …)
1483
+ * `'publish'` — given a readable source, did publishing to LiveKit work?
1484
+ *
1485
+ * Before this, both went into one entry keyed `${robot} ${kind} ${ref}` with
1486
+ * one flat `state`, in which `publish_failed` answered *"can we publish"*
1487
+ * and every other value answered *"can the source be read"* — **same key,
1488
+ * same field, two questions**, so each overwrote the other. The conflation
1489
+ * was once an occasional race; W6a's reconnect restatement made it
1490
+ * guaranteed, on every reconnect, for any camera with an active viewer.
1491
+ *
1492
+ * The facet is part of the entry's identity: a camera can perfectly well be
1493
+ * readable and unpublishable at the same moment, and that pair is exactly
1494
+ * what a developer needs to see rather than whichever fact arrived last.
1495
+ */
1496
+ facet: z.enum(['source', 'publish']),
1497
+ state: z.enum(RESOURCE_HEALTH_STATES),
1498
+ /** A short human-readable reason, or `null`. Never an exception message. */
1499
+ reason: z.string().max(200).nullable(),
1500
+ /**
1501
+ * When this state was entered — not when it was sent. A page that loads
1502
+ * late must be able to tell a failure from a minute ago from one from
1503
+ * yesterday, and a state with only a send time cannot.
1504
+ */
1505
+ changed_at_ms: z.number().int().nonnegative(),
1506
+ });
1507
+ /**
1508
+ * The current state of everything in the **org**.
1509
+ *
1510
+ * This doc said "on one robot" until the W6a review found it: the route moved
1511
+ * to org scope in `2bb67c5` and the route table forty lines above spends a
1512
+ * paragraph explaining why the per-robot reading was wrong — while the schema
1513
+ * it describes still said the old thing. Cloud, console and SDK all implement
1514
+ * org-wide correctly; contracts was the only place still saying otherwise,
1515
+ * and it is the first place a fourth consumer reads.
1516
+ *
1517
+ * A channel with no snapshot cannot answer "what is the state now?" for a
1518
+ * page that just loaded — it can only report the next change, which may be
1519
+ * hours away. Both halves or neither.
1520
+ */
1521
+ export const resourceHealthListResponse = z.object({
1522
+ resources: z.array(resourceHealthState),
1523
+ });
1524
+ /**
1525
+ * The query of `GET /api/org/health`: optionally one robot instead of the org.
1526
+ *
1527
+ * The narrowing lives in a query rather than at a per-robot path because the
1528
+ * console shows health on the robot list too, and a per-robot path would make
1529
+ * that N requests to render one screen.
1530
+ */
1531
+ export const orgHealthQuery = z
1532
+ .object({
1533
+ robot_id: z.uuid().optional().meta({
1534
+ description: 'Narrows the report to one robot. Omit it for every robot in the org. Malformed is `400 invalid_uuid` and a robot of another org is `404 not_found` — the same two answers an MCP caller gets, because the check lives in the shared service rather than on the route.',
1535
+ }),
1536
+ })
1537
+ .meta({ description: 'The optional robot filter of `GET /api/org/health`.' });
1538
+ /**
1539
+ * Org protection quotas (§12.4) — generous, server-side adjustable, visible
1540
+ * in Settings. Protection against runaway use, not a business model; a later
1541
+ * one docks onto the same dials.
1542
+ */
1543
+ export const orgQuotas = z.object({
1544
+ max_robots: z.number().int().positive(),
1545
+ max_apps: z.number().int().positive(),
1546
+ max_end_users: z.number().int().positive(),
1547
+ max_retention_bytes: z.number().int().nonnegative(),
1548
+ max_retention_writes_per_minute: z.number().int().nonnegative(),
1549
+ max_realtime_connections: z.number().int().positive(),
1550
+ /**
1551
+ * Asset storage (§4.6, W7) — **its own dial, not part of
1552
+ * `max_retention_bytes`.** A sync grows storage in jumps and time series
1553
+ * grow steadily; one dial would let the first crowd out the second, and the
1554
+ * org that hit its limit would be told to look at the wrong thing.
1555
+ *
1556
+ * **Counted per distinct blob *this org references* — not per asset row, and
1557
+ * not per object the platform stores on its behalf (W7a, D1).** The two
1558
+ * readings are indistinguishable from the number alone and a customer is
1559
+ * entitled to know which one they are being charged for.
1560
+ *
1561
+ * Within an org, sharing is free: two robots referencing the same mesh cost
1562
+ * one copy, which is what dedup means to a customer, and anything else
1563
+ * charges an org twice for a fleet of identical robots — the normal case.
1564
+ *
1565
+ * **Across orgs, sharing is not free, and W7 shipped the opposite.** Storage
1566
+ * stays globally content-addressed (one object per sha256; that efficiency
1567
+ * is real), but accounting is per-org: an org is charged for each distinct
1568
+ * blob it references and credited when its own last reference goes, whether
1569
+ * or not the blob survives for somebody else. Global refcounting made the
1570
+ * first org to sync a blob pay for it forever while every later org stored
1571
+ * it free — so the quota was evadable by anyone whose mesh someone else had
1572
+ * already uploaded, and an org's own number depended on who got there first,
1573
+ * which nobody can predict. Measured before the change: 342 bytes held by an
1574
+ * org owning no assets, with no operation able to free them.
1575
+ */
1576
+ max_asset_storage_bytes: z.number().int().nonnegative(),
1577
+ });
1578
+ /**
1579
+ * What an org is **actually using**, per quota.
1580
+ *
1581
+ * A separate shape rather than `orgQuotas.partial()`, which is what this was
1582
+ * first — and that was wrong in a way its own tests caught: a limit is
1583
+ * `positive()` because a quota of zero would forbid everything, but a
1584
+ * **usage** of zero is the honest answer for every org on the day it signs
1585
+ * up. Reusing one schema for a limit and a measurement is the same mistake as
1586
+ * letting an empty bucket and a zero average share a representation, which
1587
+ * this wave spent a lot of care avoiding one layer up.
1588
+ *
1589
+ * Every field is optional because a quota we do not measure must be
1590
+ * **absent**, never reported as `0` — "not measured" and "measured as zero"
1591
+ * are different facts, and a dashboard that renders the first as the second
1592
+ * is lying quietly.
1593
+ */
1594
+ export const orgQuotaUsageCounts = z.object({
1595
+ max_robots: z.number().int().nonnegative(),
1596
+ max_apps: z.number().int().nonnegative(),
1597
+ max_end_users: z.number().int().nonnegative(),
1598
+ max_retention_bytes: z.number().int().nonnegative(),
1599
+ max_asset_storage_bytes: z.number().int().nonnegative(),
1600
+ max_retention_writes_per_minute: z.number().int().nonnegative(),
1601
+ max_realtime_connections: z.number().int().nonnegative(),
1602
+ }).partial();
1603
+ /** Limits beside what is actually used — a limit alone tells nobody where they stand. */
1604
+ export const orgQuotaUsage = z.object({ quotas: orgQuotas, usage: orgQuotaUsageCounts });
1605
+ /** One bucket is one minute. Stated here so the cloud and any client agree without guessing. */
1606
+ export const LATENCY_BUCKET_MS = 60_000;
1607
+ /**
1608
+ * Latency buckets are **platform telemetry, not a customer datapoint**, and
1609
+ * this short retention is why that distinction was worth making: the cloud
1610
+ * pings every bridge every 2 seconds, ~43 200 measurements per robot per day,
1611
+ * and a sparkline needs about 60 points per hour. Seven days is generous for
1612
+ * what reads it and costs the org's retention quota nothing, because it is not
1613
+ * counted against it.
1614
+ */
1615
+ export const BRIDGE_LATENCY_RETENTION_DAYS = 7;
1616
+ /**
1617
+ * Every read of the durable run history and the latency buckets: the three
1618
+ * org-wide ones the fleet overview is built on, and the one robot-scoped door
1619
+ * a client app has into the same table.
1620
+ *
1621
+ * | Route | Query | Answer |
1622
+ * |---|---|---|
1623
+ * | `GET /api/org/jobs` | `jobRunQuery` | `jobRunListResponse` — newest first, cursor-paged over the durable `seq` |
1624
+ * | `GET /api/org/jobs/summary` | `jobRunSummaryQuery` | `jobRunSummary` — three numbers over the window the caller named |
1625
+ * | `GET /api/org/latency` | `orgLatencyQuery` | `orgLatencyResponse` — one series per robot, truncation named |
1626
+ * | `GET /api/robots/:id/jobs/history` | `jobRunQuery` | `jobRunListResponse` — the same read, robot-scoped, developers **and** clients |
1627
+ *
1628
+ * **Written down here because the last time a delta shipped shapes without
1629
+ * their paths, a teammate had to ask three separate people** — see
1630
+ * `robotDeletionSummary`'s neighbouring table, which exists for exactly that
1631
+ * reason. The shapes landed one wave before the routes did, so this table is
1632
+ * the only place the two halves meet.
1633
+ *
1634
+ * Three things about them are worth stating rather than inferring:
1635
+ *
1636
+ * **The three `/api/org/…` reads are org-wide, and `?robot_id=` narrows
1637
+ * them** — the same choice `GET /api/org/health` already made, for the same
1638
+ * reason: the overview screen shows every robot at once, and a per-robot path
1639
+ * would make one screen N requests.
1640
+ *
1641
+ * **Those three are developer-only, and that is a property of their scope,
1642
+ * not of the data.** An org-wide read has no client meaning: an end user is
1643
+ * scoped to the robots their app assigns, never to an org.
1644
+ *
1645
+ * **The client-facing read of the same table is
1646
+ * `GET /api/robots/:id/jobs/history`** — robot-scoped, one route for
1647
+ * developers and clients like every other robot-scoped read (`.../jobs`,
1648
+ * `.../assets`, `.../datapoints`), never a parallel `/api/client/…` twin. An
1649
+ * end user reaches it only when their role's `capabilities.action_history`
1650
+ * says so — otherwise `403 capability_required`, naming the capability — and
1651
+ * sees only runs on slugs their role grants. On this route `?robot_id=` is
1652
+ * not a filter: the path already names the robot, and a query naming a
1653
+ * different one is refused rather than quietly answered about the path's.
1654
+ *
1655
+ * **It discloses the actor, and that is what a developer weighs before
1656
+ * granting the capability.** A `jobRun` names who invoked it — `jobActor`
1657
+ * carries an email — so an end user reading a robot's history learns which
1658
+ * other people have been driving that machine. Robot scope plus a role
1659
+ * capability is what makes that a decision a developer takes per role,
1660
+ * instead of something every session gets: an end-user-facing
1661
+ * `GET /api/org/jobs` would have handed over the whole org's actors with no
1662
+ * such decision anywhere, which is why there is none.
1663
+ *
1664
+ * **A page can be shorter than `limit` while `next_cursor` is non-null**, on
1665
+ * the robot-scoped route specifically: the slug filter is applied to the
1666
+ * page the store returned, so a role granting one slug in ten sees thin — and
1667
+ * sometimes empty — pages. That is what `jobRunListResponse.next_cursor`'s
1668
+ * own doc comment means by a promise rather than an observation; a client
1669
+ * keeps reading until it is null.
1670
+ *
1671
+ * **Neither window is optional, and neither has a default.** A summary over
1672
+ * an unnamed window is a number nobody can reproduce; an unbounded latency
1673
+ * window is a response size chosen by whoever forgot to pass one. Each
1674
+ * query's own doc comment says which of those two reasons applies to it.
1675
+ */
1676
+ /** Seven days x 1440 buckets x N robots is otherwise an unbounded response. */
1677
+ export const MAX_LATENCY_BUCKETS_PER_RESPONSE = 20_000;
1678
+ export const latencyBucket = z.object({
1679
+ /** Truncated to the minute. */
1680
+ bucket_at: z.iso.datetime(),
1681
+ /**
1682
+ * `null` exactly when `samples` is 0. A minute in which the robot was offline
1683
+ * throughout has **no** latency; writing `0` would put the number meaning
1684
+ * "perfectly fast" into the state meaning "not there at all".
1685
+ */
1686
+ min_ms: z.number().nonnegative().nullable(),
1687
+ avg_ms: z.number().nonnegative().nullable(),
1688
+ max_ms: z.number().nonnegative().nullable(),
1689
+ samples: z.number().int().nonnegative(),
1690
+ /**
1691
+ * Milliseconds of this bucket the cloud held the robot online.
1692
+ *
1693
+ * A duration and **not a ratio**: a ratio needs a denominator, and here that
1694
+ * would be expected pings per minute — `pingIntervalMs`, which is
1695
+ * configurable and is shrunk in tests. A stored value whose meaning depends
1696
+ * on a configuration variable is not comparable across the time it is stored
1697
+ * for. A client divides by `LATENCY_BUCKET_MS` if it wants a fraction.
1698
+ */
1699
+ online_ms: z.number().int().min(0).max(LATENCY_BUCKET_MS),
1700
+ });
1701
+ export const robotLatencySeries = z.object({
1702
+ robot_id: z.uuid(),
1703
+ buckets: z.array(latencyBucket),
1704
+ });
1705
+ /**
1706
+ * `GET /api/org/latency`'s query.
1707
+ *
1708
+ * **Both bounds are required**, for a reason narrower than
1709
+ * `jobRunSummaryQuery`'s: this table holds a bucket per robot per minute for
1710
+ * `BRIDGE_LATENCY_RETENTION_DAYS`, so "everything" is up to 10 080 rows per
1711
+ * robot, and a default window would be a response size chosen by whoever
1712
+ * forgot to pass one. `MAX_LATENCY_BUCKETS_PER_RESPONSE` still bounds the
1713
+ * answer; required bounds are what let a caller decide *which* buckets they
1714
+ * get instead of discovering the ceiling ate the ones they wanted.
1715
+ *
1716
+ * `wireTimestampMs` rather than a plain integer, for its own documented
1717
+ * reason: the union's input branch is what a query string actually carries,
1718
+ * and the year bound is what keeps `253402300800000` from reaching the
1719
+ * Postgres bind path as a `500` where a `400` belongs.
1720
+ */
1721
+ export const orgLatencyQuery = z
1722
+ .object({
1723
+ from_ms: wireTimestampMs,
1724
+ /** Exclusive — half-open `[from, to)`, the convention every other query here already follows (DEF-062). */
1725
+ to_ms: wireTimestampMs,
1726
+ /**
1727
+ * One robot's own sparkline. `z.uuid()`, because the column is one —
1728
+ * the same fix in the same place `auditQuery.actor_id` documents at
1729
+ * length: a non-uuid reaching Postgres as a uuid parameter answers
1730
+ * `500 internal_error`, and a 500 explains nothing.
1731
+ */
1732
+ robot_id: z.uuid().optional(),
1733
+ })
1734
+ .strict()
1735
+ /**
1736
+ * Refused here rather than in the route, so an inverted window comes back
1737
+ * as part of the same `validation_error` every other bad parameter
1738
+ * produces. Strict, not `<=`: an empty half-open window is a query with no
1739
+ * answer, and a caller who asked for one has made a mistake worth being
1740
+ * told about rather than being handed an empty series that reads like a
1741
+ * quiet robot.
1742
+ *
1743
+ * **The published artifact cannot express this**, and that is worth saying
1744
+ * out loud rather than leaving a reader to assume the JSON Schema is the
1745
+ * whole contract: a cross-field comparison has no JSON Schema rendering, so
1746
+ * `org-latency-query.schema.json` describes two independent integers and
1747
+ * validates an inverted window happily. The cloud is the only enforcement
1748
+ * point for the ordering; a generated client that validates against the
1749
+ * artifact alone will get a `400` from the route it did not predict, which
1750
+ * is the correct outcome and not a drift bug.
1751
+ */
1752
+ .refine((query) => query.from_ms < query.to_ms, {
1753
+ message: 'from_ms must be strictly before to_ms',
1754
+ path: ['from_ms'],
1755
+ });
1756
+ export const orgLatencyResponse = z.object({
1757
+ series: z.array(robotLatencySeries),
1758
+ from_ms: z.number().int().nonnegative(),
1759
+ to_ms: z.number().int().nonnegative(),
1760
+ truncated: z.boolean(),
1761
+ /**
1762
+ * Which ceiling cut the response short, `null` when nothing did — borrowed
1763
+ * from `historySamplesResponse.truncated_by` rather than invented a second
1764
+ * time, for its reason: one boolean cannot carry two different remedies.
1765
+ */
1766
+ truncated_by: z.enum(['limit', 'bytes']).nullable(),
1767
+ });
1768
+ /**
1769
+ * How long a usage window may be, in days. **Refused above this, not capped** —
1770
+ * the rule `jobRunQuery.limit` already states: a caller who asked for more than
1771
+ * the platform will answer is owed a `400` naming the field, not a quietly
1772
+ * shorter answer they will mistake for the whole picture.
1773
+ *
1774
+ * 366 rather than 365, so "the last full year" is expressible in a leap year.
1775
+ */
1776
+ export const USAGE_WINDOW_MAX_DAYS = 366;
1777
+ /**
1778
+ * The five things the meter records (spec D1).
1779
+ *
1780
+ * Storage is two metrics and not one summed byte count, for
1781
+ * `org_quotas.max_asset_storage_bytes`'s own reason applied to billing: a sync
1782
+ * grows storage in jumps and time series grow steadily, and one number would
1783
+ * let the first crowd out the second on the invoice the same way it would on
1784
+ * the quota.
1785
+ */
1786
+ export const usageMetric = z.enum(['api_calls', 'live_session_ms', 'retention_bytes', 'asset_bytes', 'robot_online_ms']);
1787
+ /**
1788
+ * A UTC calendar day, `YYYY-MM-DD`.
1789
+ *
1790
+ * A string and not a millisecond instant, because the thing being described is
1791
+ * a day and not a moment: a `Date` here would carry a time and a zone the
1792
+ * column does not have, and every bug in this area starts with one being
1793
+ * silently converted.
1794
+ *
1795
+ * **The regex checks shape, not validity** — `2026-13-45` and `2026-02-30`
1796
+ * both match `\d{4}-\d{2}-\d{2}$` — so the `.refine()` below round-trips the
1797
+ * string through `Date`'s UTC parser and rejects anything that does not come
1798
+ * back unchanged: `2026-13-45` parses to `Invalid Date`, and `2026-02-30`
1799
+ * (which `Date` rolls over rather than rejects) comes back as `2026-03-02`,
1800
+ * a mismatch either way. Same defect class as `auditQuery.from_ms`'s
1801
+ * `253402300800000`: a value that is the right *shape* reaching the Postgres
1802
+ * bind path for a `date` column and answering `500` where `400` belongs.
1803
+ *
1804
+ * **What the published artifact does not say:** `wireTimestampMs`'s own
1805
+ * note applies unchanged — a `.refine()` has no JSON Schema rendering, so
1806
+ * `org-usage-query.schema.json` shows only the shape-checking `pattern` and
1807
+ * a generated client that validates against the artifact alone will believe
1808
+ * `2026-02-30` is acceptable. The runtime is the authority for this field.
1809
+ */
1810
+ export const usageDay = z
1811
+ .string()
1812
+ .regex(/^\d{4}-\d{2}-\d{2}$/, 'must be a UTC calendar day, YYYY-MM-DD')
1813
+ .refine((day) => {
1814
+ const parsed = new Date(`${day}T00:00:00.000Z`);
1815
+ return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === day;
1816
+ }, { message: 'must be a UTC calendar day, YYYY-MM-DD' });
1817
+ /**
1818
+ * **The window is inclusive at both ends**, unlike every millisecond window in
1819
+ * this file (`from_ms`/`to_ms`, half-open per DEF-062).
1820
+ *
1821
+ * That inconsistency is deliberate and is stated here rather than left to be
1822
+ * discovered: a calendar day is a unit, not an instant, and a person asking for
1823
+ * July will write `from_day=2026-07-01&to_day=2026-07-31`. A half-open day
1824
+ * window would silently drop the 31st.
1825
+ *
1826
+ * Both parameters are required and have no default — the rule `/api/org/latency`
1827
+ * and `/api/org/jobs/summary` already follow. "This month" is a question only
1828
+ * the caller's calendar can answer, and a default window would be a query size
1829
+ * chosen by whoever forgot to pass one.
1830
+ *
1831
+ * **The published artifact cannot express any of this**, and that is worth
1832
+ * saying out loud rather than leaving a reader to assume the JSON Schema is
1833
+ * the whole contract, for `orgLatencyQuery`'s own reason: a cross-field
1834
+ * comparison has no JSON Schema rendering, so `org-usage-query.schema.json`
1835
+ * describes two independent pattern-matched strings and validates an
1836
+ * inverted window happily — the cloud is the only enforcement point for the
1837
+ * ordering. The artifact is equally silent about the inclusivity called out
1838
+ * above: nothing in the shape distinguishes an inclusive day window from a
1839
+ * half-open one, that is a fact about behaviour, not a field (the same gap
1840
+ * `historyQuery`/`historyBucketsResponse` name for their own half-open
1841
+ * boundary). And it says nothing about `USAGE_WINDOW_MAX_DAYS` at all — the
1842
+ * constant is not wired into this schema as a check on the span between
1843
+ * `from_day` and `to_day`; the cloud route is where a caller who asked for
1844
+ * more than the ceiling is refused, so a generated client validating against
1845
+ * the artifact alone can build a five-year window and get a `400` from the
1846
+ * route it did not predict.
1847
+ */
1848
+ export const orgUsageQuery = z
1849
+ .object({ from_day: usageDay, to_day: usageDay })
1850
+ .strict()
1851
+ .refine((query) => query.from_day <= query.to_day, {
1852
+ message: 'from_day must not be after to_day',
1853
+ path: ['from_day'],
1854
+ });
1855
+ /**
1856
+ * One day's reading for one metric.
1857
+ *
1858
+ * **`app_id` is `null` when the consumer is the org itself** (spec D2), and
1859
+ * what that `null` means for billing depends on the *metric*, not on
1860
+ * `app_id` alone. `api_calls` and `live_session_ms` are attributable to an
1861
+ * app: a `null` app_id on those two is the developer console's own traffic,
1862
+ * deliberately *not* billable. `retention_bytes`, `asset_bytes` and
1863
+ * `robot_online_ms` have no app dimension at all — every row for those three
1864
+ * carries `app_id: null` unconditionally, and every one is billable org-level
1865
+ * consumption. **A reader must check `metric` before treating `app_id ===
1866
+ * null` as "not billable"** — for three of the five metrics that reading is
1867
+ * always wrong.
1868
+ *
1869
+ * `app_name` is `null` whenever `app_id` is, and also when the app has since
1870
+ * been deleted — usage outlives the app it was attributed to, because an org
1871
+ * still owes for what it used. A UUID alone on an invoice line helps nobody,
1872
+ * and a copy of the name stored on every row would be a second truth that
1873
+ * drifts on the first rename.
1874
+ *
1875
+ * **What this number cannot promise**, and the bound is conditional rather
1876
+ * than flat. `api_calls` and `live_session_ms` are aggregated in memory and
1877
+ * written every 30 seconds.
1878
+ *
1879
+ * *While those writes are landing*, a `kill -9` loses up to 30 seconds of
1880
+ * counting — never more, and never against the caller, since an unflushed
1881
+ * count is simply not billed.
1882
+ *
1883
+ * *While they are failing* — an unreachable database, say — that bound does
1884
+ * not hold at all: everything counted since the last successful flush is
1885
+ * held in memory, deliberately uncapped, and a `kill -9` loses all of it.
1886
+ * The trade is intentional (dropping billing data to bound process memory is
1887
+ * the worse half of it), but "at most one interval" describes a platform
1888
+ * whose writes are landing, not a guarantee that survives an outage. This
1889
+ * sentence used to say "never more", and it was false.
1890
+ *
1891
+ * A row the database rejects **permanently** — most concretely one whose org
1892
+ * has been deleted since the count, since a usage row's `org_id` is `ON
1893
+ * DELETE NO ACTION` — is written off instead: given up on, reported with a
1894
+ * count, and never billed. That is a deliberate loss, and it is the smaller
1895
+ * one. Before it, a single such row failed the whole batched write on every
1896
+ * retry, forever, and stopped `api_calls` and `live_session_ms` reaching the
1897
+ * database for **every** org on the platform.
1898
+ *
1899
+ * A graceful shutdown loses nothing **provided its final flush succeeds**.
1900
+ * If that write fails, the process reports how many rows it is carrying and
1901
+ * exits carrying them — there is no second attempt, because there is no
1902
+ * longer a process to make one.
1903
+ *
1904
+ * The other three metrics never travel this path. They are sampled from
1905
+ * other tables on their own timer and can lag; what a missed sample costs,
1906
+ * per metric, is in the docs' `/api/org/usage` notes.
1907
+ */
1908
+ export const usageRow = z.object({
1909
+ app_id: z.uuid().nullable(),
1910
+ app_name: z.string().nullable(),
1911
+ metric: usageMetric,
1912
+ day: usageDay,
1913
+ value: z.number().int().nonnegative(),
1914
+ });
1915
+ /** The window is echoed back for `orgLatencyResponse`'s reason: a rendered total has to be able to say which window it describes. */
1916
+ export const orgUsageResponse = z.object({
1917
+ rows: z.array(usageRow),
1918
+ from_day: usageDay,
1919
+ to_day: usageDay,
1920
+ });
1921
+ /** `PATCH /api/robots/:id` — rename the robot. Display-only: nothing references robot names. */
1922
+ export const patchRobotRequest = z.object({ name: z.string().min(1).max(63) }).strict();
1923
+ /**
1924
+ * `POST /api/robots/:id/config/rename-slug` — atomic server-side rename:
1925
+ * rewrites the **draft** config, every app-role grant carrying
1926
+ * `{robot_id, from}`, and the recorded history rows, in one transaction.
1927
+ * Job runs and audit events keep the old slug as historical fact. The
1928
+ * published config is immutable, so the caller must publish afterwards
1929
+ * (`requires_publish`); samples arriving between rename and the applied
1930
+ * publish still land under the old slug — named residual, not migrated.
1931
+ * Second residual in that same window: grants and the draft already name
1932
+ * `to`, but the still-published config exposes only `from` until the
1933
+ * publish lands — an end user's app has no working name for the datapoint
1934
+ * at all for however long that gap lasts, since `to` isn't published yet
1935
+ * and `from` no longer has a grant behind it. The console must publish
1936
+ * immediately after a rename to keep this window short; nothing server-side
1937
+ * closes it.
1938
+ * This schema only enforces slug *shape*; whether `to` is reserved or
1939
+ * already in use on this robot is checked once, behind the cloud's
1940
+ * `validation.ts` door — one door, not a second copy of that rule here.
1941
+ */
1942
+ export const renameSlugRequest = z.object({ from: slug, to: slug }).strict();
1943
+ export const renameSlugResponse = z.object({
1944
+ rewritten_grants: z.number().int().nonnegative(),
1945
+ history_moved: z.boolean(),
1946
+ requires_publish: z.literal(true)
1947
+ });
1948
+ /**
1949
+ * `GET /api/robots/:id/config/slug-usage/:slug` — what a rename would touch;
1950
+ * feeds the console's confirm dialog.
1951
+ *
1952
+ * `alert_count` (spec `2026-08-28-alerts-and-datapoint-modal-design`, D5)
1953
+ * joined the atomic rename transaction alongside grants and history: alerts
1954
+ * are keyed by `(robot_id, slug)` too, and a rename that silently moved the
1955
+ * alert row while the usage preview stayed silent about it would show a
1956
+ * developer a smaller blast radius than the rename actually has.
1957
+ */
1958
+ export const slugUsageResponse = z.object({
1959
+ grant_count: z.number().int().nonnegative(),
1960
+ app_identifiers: z.array(z.string()),
1961
+ has_recorded_history: z.boolean(),
1962
+ alert_count: z.number().int().nonnegative(),
1963
+ });