@fleetless/contracts 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +68 -2
  2. package/CODE_OF_CONDUCT.md +83 -0
  3. package/CONTRIBUTING.md +136 -0
  4. package/README.md +49 -13
  5. package/SECURITY.md +55 -0
  6. package/artifacts/openapi.json +1 -1
  7. package/artifacts/routes.json +1 -1
  8. package/dist/alerts.d.ts +19 -24
  9. package/dist/alerts.js +18 -24
  10. package/dist/app-users.d.ts +7 -6
  11. package/dist/app-users.js +6 -6
  12. package/dist/apps.d.ts +21 -25
  13. package/dist/apps.js +40 -51
  14. package/dist/assets.d.ts +70 -132
  15. package/dist/assets.js +130 -223
  16. package/dist/audit.d.ts +11 -11
  17. package/dist/audit.js +25 -51
  18. package/dist/client-auth.d.ts +4 -4
  19. package/dist/client-auth.js +3 -4
  20. package/dist/common.d.ts +27 -35
  21. package/dist/common.js +26 -35
  22. package/dist/config-issues.d.ts +4 -3
  23. package/dist/config-issues.js +7 -6
  24. package/dist/config.d.ts +31 -37
  25. package/dist/config.js +81 -110
  26. package/dist/errors.d.ts +4 -3
  27. package/dist/errors.js +61 -87
  28. package/dist/identity.d.ts +18 -21
  29. package/dist/identity.js +17 -21
  30. package/dist/index.d.ts +4 -4
  31. package/dist/index.js +12 -13
  32. package/dist/introspection.d.ts +7 -6
  33. package/dist/introspection.js +6 -6
  34. package/dist/jobs.d.ts +12 -12
  35. package/dist/jobs.js +20 -25
  36. package/dist/mcp.d.ts +11 -12
  37. package/dist/mcp.js +10 -12
  38. package/dist/oauth.d.ts +13 -18
  39. package/dist/oauth.js +13 -19
  40. package/dist/protocol.d.ts +51 -62
  41. package/dist/protocol.js +107 -139
  42. package/dist/realtime.d.ts +53 -68
  43. package/dist/realtime.js +77 -103
  44. package/dist/rest.d.ts +182 -243
  45. package/dist/rest.js +301 -395
  46. package/dist/routes.d.ts +4 -3
  47. package/dist/routes.js +3 -2
  48. package/package.json +12 -7
package/dist/rest.js CHANGED
@@ -6,8 +6,8 @@ import { configState, rateThrottleHz, robotConfigDoc, snapshotIntervalSeconds, v
6
6
  import { rosGraph, typeDefinition } from './introspection.js';
7
7
  import { job } from './jobs.js';
8
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.
9
+ * REST shapes of the robot resource: create, list, get, and the built-in
10
+ * `bridge_state` datapoint read.
11
11
  */
12
12
  export const robot = z.object({
13
13
  id: z.uuid().meta({
@@ -30,8 +30,8 @@ export const createRobotRequest = z.object({
30
30
  name: z.string().min(1).max(63),
31
31
  });
32
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.
33
+ * The robot token binds one bridge to one robot. It is returned exactly once,
34
+ * here; the cloud stores only a hash of it.
35
35
  */
36
36
  export const robotToken = z.string().regex(/^frt_[0-9a-f]{32}$/);
37
37
  export const createRobotResponse = z.object({
@@ -39,10 +39,10 @@ export const createRobotResponse = z.object({
39
39
  token: robotToken,
40
40
  });
41
41
  /**
42
- * How many things a robot exposes, per kind (spec `2026-08-21-exposure-and-revoke-design` D1).
42
+ * How many things a robot exposes, per kind.
43
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
44
+ * **Five numbers, never a sum.** `robotDeletionSummary.slug_count` makes the
45
+ * same call for the same reason: fold cameras in and the sentence "this deletes
46
46
  * N slugs and M cameras" counts them twice. A list row has the same problem.
47
47
  *
48
48
  * **Counted from the published configuration, and excluding the built-ins.**
@@ -79,7 +79,7 @@ export const robotListResponse = z.object({
79
79
  });
80
80
  /**
81
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
82
+ * is the capture time at the bridge; for the cloud-observed
83
83
  * built-in `bridge_state` it is the time the cloud observed the state.
84
84
  */
85
85
  export const datapointValue = z.object({
@@ -91,15 +91,14 @@ export const datapointValue = z.object({
91
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
92
  }),
93
93
  });
94
- /* ------------------------------------------------------------------ W2 --
94
+ /* ------------------------------------------------------------------------
95
95
  * Exposure: the configuration resource, introspection, types, and the
96
- * datapoint surface generated from the published configuration (spec §4,
97
- * §11.2).
96
+ * datapoint surface generated from the published configuration.
98
97
  */
99
98
  /**
100
99
  * One robot in full: what the list shows, plus what only the detail view
101
100
  * needs — which bridge build is connected, why the last hello was refused,
102
- * and where the configuration stands (spec §15.2, tab 1).
101
+ * and where the configuration stands.
103
102
  */
104
103
  export const robotDetailResponse = z.object({
105
104
  ...robotListItem.shape,
@@ -163,19 +162,16 @@ export const configDraftResponse = z.object({
163
162
  * the server parses it, and there is exactly one account of what the
164
163
  * configuration says.
165
164
  *
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.
165
+ * It also settles who owns parsing. The **server** refuses text that is not
166
+ * valid YAML, with the line and column, and stores everything else — including
167
+ * valid YAML that is not a fleetless document, which comes back with
168
+ * `doc: null` and its issues. An editor may check as you type so the answer is
169
+ * immediate; the server checks because it is the one that decides. Two checks
170
+ * of one question, and the server's is the one that binds.
171
+ *
172
+ * A stored source that does not parse to a document is therefore a
173
+ * **represented state**, not an error: no document at all. See
174
+ * `configDraftResponse` above.
179
175
  */
180
176
  export const putConfigDraftRequest = z.object({ source: z.string().max(1_000_000) });
181
177
  /** Publishing freezes the draft into the next immutable version. */
@@ -227,11 +223,11 @@ export const fetchTypesResponse = z.object({
227
223
  /**
228
224
  * What a client can read on this robot: the built-ins plus everything the
229
225
  * published configuration exposes. This is the seed of the generated
230
- * per-robot API (§11.2).
226
+ * per-robot API.
231
227
  *
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`.**
228
+ * **The OpenAPI rendering is `artifacts/openapi.json`**, derived from the
229
+ * route manifest in `routes.ts` and these schemas by
230
+ * `scripts/export-schemas.ts`.
235
231
  */
236
232
  export const datapointDescriptor = z.object({
237
233
  slug: slug.meta({ description: 'The name a client reads this datapoint by.' }),
@@ -258,8 +254,8 @@ export const datapointListResponse = z.object({
258
254
  }),
259
255
  });
260
256
  /**
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.
257
+ * The built-in `robot_details` datapoint: static properties the developer
258
+ * maintains. Bounded so one robot cannot become a document store.
263
259
  */
264
260
  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
261
  /** What `PUT /api/robots/:id/details` answers: the stored document, which is the one that was sent. */
@@ -269,8 +265,8 @@ export const putRobotDetailsResponse = z.object({
269
265
  }),
270
266
  });
271
267
  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.
268
+ /* ------------------------------------------------------------------------
269
+ * The command surface, and what a role may be granted.
274
270
  *
275
271
  * The routes, written down because cloud, console and SDK each need them and
276
272
  * a body schema does not imply a path:
@@ -284,7 +280,7 @@ export const putRobotDetailsRequest = z.object({ details: robotDetailsDoc });
284
280
  * | `GET /api/robots/:id/exposures` | — | `exposureListResponse` |
285
281
  *
286
282
  * **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
283
+ * across all kinds and a role grant is `{robot, slug}` with no kind in
288
284
  * it — so a path segment naming the kind would demand a fact the permission
289
285
  * model deliberately does not carry. The cloud already knows from the
290
286
  * published configuration whether a slug is an action or a service; a caller
@@ -297,7 +293,7 @@ export const putRobotDetailsRequest = z.object({ details: robotDetailsDoc });
297
293
  * job and so is not under `/jobs`.
298
294
  */
299
295
  /**
300
- * Invoke an action or call a service; parameters by field path (§4.4).
296
+ * Invoke an action or call a service; parameters by field path.
301
297
  *
302
298
  * Flat, keyed by `parameterSpec.name` — see `cloudInvoke.params` for why the
303
299
  * flat form is the one that makes a refusal legible.
@@ -307,7 +303,7 @@ export const invokeRequest = z.object({
307
303
  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
304
  }),
309
305
  /**
310
- * How long **this call** is worth waiting for, in milliseconds (W6b).
306
+ * How long **this call** is worth waiting for, in milliseconds.
311
307
  *
312
308
  * **Absent means `DEFAULT_PATIENCE_MS`** — today's behaviour, unchanged, for
313
309
  * every caller who does not care. It is optional because most callers have
@@ -337,37 +333,28 @@ export const invokeRequest = z.object({
337
333
  }),
338
334
  });
339
335
  /**
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.
336
+ * The answer to an invoke. The job id is informative: state is observed by
337
+ * slug afterwards, over polling or a subscription.
342
338
  */
343
339
  /**
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.
340
+ * The body of a cancel. **Every field optional, and the body itself may be
341
+ * absent** — `POST .../cancel` takes no body at all in its simplest form, and
342
+ * a schema that demanded one would break every caller on the one verb that
343
+ * stops a machine.
354
344
  *
355
345
  * **`.strict()`, and that is the whole point of the shape.** A plain object
356
346
  * strips unknown keys, so a caller who *means* to name a job and misspells the
357
347
  * field — `jobId` for `job_id` — has their id silently removed and gets the
358
348
  * **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.
349
+ * did not make. A `?force=true` flag fails *safe* on a typo, because a
350
+ * misspelled `force` simply does not force. Stripping here fails unsafe, so
351
+ * unknown keys are refused instead.
364
352
  *
365
353
  * `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.
354
+ * deliberate: over REST an absent body is how a caller says "cancel whatever is
355
+ * running". On the socket, `clientCancel.job_id` is required-and-nullable
356
+ * instead, because a frame is assembled fresh by a client that knows this
357
+ * contract — there, `null` is a decision and an omission is a bug.
371
358
  */
372
359
  export const cancelRequest = z.object({
373
360
  job_id: z.uuid().nullable().optional().meta({
@@ -375,18 +362,17 @@ export const cancelRequest = z.object({
375
362
  }),
376
363
  }).strict();
377
364
  /**
378
- * The query of a live release (W6b): `DELETE .../live?session_id=<uuid>`.
365
+ * The query of a live release: `DELETE .../live?session_id=<uuid>`.
379
366
  *
380
367
  * 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
368
+ * deletion. A body on a DELETE is carried inconsistently by proxies and by
383
369
  * `fetch` itself, and this call runs from a browser tab that is often closing.
384
370
  *
385
371
  * **`.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.
372
+ * `?session_id=` would release **every** one of an identity's holds and strand
373
+ * its other tabs, which is precisely what this field exists to prevent. A
374
+ * refused typo costs a round trip; a stripped one stops a robot somebody else
375
+ * is watching.
390
376
  *
391
377
  * Absent means today's meaning: release **all** of this identity's holds on
392
378
  * this camera. A client that has lost its id, or is going away entirely, still
@@ -443,14 +429,12 @@ export const publishRequest = z.object({
443
429
  * The **most recent** job on a slug — running or already finished — or null
444
430
  * only when nothing has ever run there.
445
431
  *
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
432
+ * A slug can be observed two equally valid ways, by polling this route or by
433
+ * subscribing. A route that forgot a job the moment it settled would let a
434
+ * poller see only `running`, then `null`: succeeded, failed, cancelled, `lost`
435
+ * and never-invoked would all become the same answer, and the promise that a
436
+ * lost job is said out loud would hold for subscribers and silently not hold
437
+ * for anyone polling. It is also the recovery `command_outcome_unknown` points
454
438
  * a caller to.
455
439
  *
456
440
  * Read `job.state` to tell a live job from a finished one; that is what the
@@ -463,20 +447,18 @@ export const jobResponse = z.object({
463
447
  });
464
448
  /**
465
449
  * Every job the platform currently believes this robot has — `GET
466
- * /api/robots/:id/jobs` (W6b).
450
+ * /api/robots/:id/jobs`.
467
451
  *
468
452
  * `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.
453
+ * slug first. Two kinds of job break that assumption: a reconnecting bridge
454
+ * can name a job the cloud has **no row for**, and the cloud adopts it; and a
455
+ * configuration change can leave a job on a slug the document no longer
456
+ * contains. Both are jobs nobody can ask about, because asking requires
457
+ * already knowing what to ask for.
458
+ *
459
+ * So this route answers the question the per-slug route cannot: not "is
460
+ * something running here", but "what is this robot doing". A cloud that has
461
+ * just reconciled a robot's `hello.active_jobs` has exactly this list.
480
462
  *
481
463
  * The array is ordered newest first and is **never null**: a robot doing
482
464
  * nothing answers `{ jobs: [] }`. "Nothing is running" and "we did not look"
@@ -484,27 +466,23 @@ export const jobResponse = z.object({
484
466
  * distinction `robotDeletionSummary` was made all-required for.
485
467
  *
486
468
  * **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.
469
+ * `jobResponse` would answer for that slug.** This is not a history endpoint.
470
+ * Returning every job a registry still holds is unbounded in both count and
471
+ * payload for a robot that has been working all day, and the one thing this
472
+ * route exists to answer — *what is this robot doing* — would be the first
473
+ * line of a scroll. The durable history has its own routes.
495
474
  *
496
475
  * A settled job stays visible as its slug's current entry until something
497
476
  * else runs there, which is what makes a job that just failed still findable.
498
477
  * Read `state` to tell a live one from a finished one, exactly as with
499
478
  * `jobResponse`.
500
479
  */
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.
480
+ /* ------------------------------------------------------------------------
481
+ * Identity. Written down here for the same reason the command routes are:
482
+ * **a body schema does not imply a path**, and every consumer would otherwise
483
+ * derive nine paths independently from one implementation.
506
484
  *
507
- * **Two identity spaces, two prefixes** (2026-09-05 app-user-auth, D1). The
485
+ * **Two identity spaces, two prefixes.** The
508
486
  * `/api/org/` vs `/api/end-users/` split this table once insisted on, and the
509
487
  * one pool that replaced it, are both gone. `/api/org/users` is the **team**:
510
488
  * Fleetless users, console access, a tier each. An app's users live under
@@ -533,22 +511,18 @@ export const jobResponse = z.object({
533
511
  * model survives in production while the contract says otherwise.
534
512
  *
535
513
  * **`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
514
+ * address**, known or not. It is the one route where saying nothing about
515
+ * whether an account exists is not a preference but the entire point: any
516
+ * status, body or timing difference between the two cases is an
517
+ * account-enumeration oracle. Note *timing* — a route that only sends mail for
518
+ * a real address must not become measurably faster for an unknown one. Email is
519
+ * **globally unique**, so a bare address names at most one account and the
520
+ * route mails the one match, if any. See `passwordResetRequest`.
521
+ *
522
+ * **Both surfaces get the password routes, mirrored.** An end user and a
523
+ * console user each need a way to change and to reset a password.
524
+ * `passwordChangeRequest` is shared because the operation is identical; the
525
+ * **prefix** is what says
552
526
  * which session is being spent, exactly as it does for `login`. The two *reset*
553
527
  * requests are separate shapes rather than one, because the surfaces identify a
554
528
  * person differently: a Fleetless user by a globally unique address, an app
@@ -561,15 +535,14 @@ export const jobResponse = z.object({
561
535
  * Re-issuing is the honest way to keep the promise: revoke everything, hand the
562
536
  * caller a new pair. Anything else means the caller keeps working until their
563
537
  * 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
538
+ * indistinguishable from the change having failed.
539
+ *
540
+ * **Every mailed link must carry what the page needs to act on it.** The
541
+ * failure mode is a link mailed to a page that cannot handle it: a reset link
542
+ * pointing at the *request* page, an accept link pointing at a `404`. Worse and
543
+ * quieter: **both surfaces mailing the identical reset URL**, when the page it
544
+ * points at posts to only one of the two routes, so the other surface's token
545
+ * answers `token_spent` forever. A URL that does not say which
573
546
  * surface minted it cannot be routed correctly by anything.
574
547
  *
575
548
  * So the link shapes are fixed here rather than in whichever repo builds them.
@@ -599,11 +572,10 @@ export const jobResponse = z.object({
599
572
  * The strings themselves live in `cloud/src/portal-paths.ts`, read by the
600
573
  * route that serves each page AND by the builder that mails it — one constant,
601
574
  * 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.
575
+ * the mailer, the page and this table can each spell a path differently, and
576
+ * nothing catches it because nothing shares a string.
605
577
  *
606
- * ## W7 — the asset store (§4.6)
578
+ * ## The asset store
607
579
  *
608
580
  * | route | who | role capability |
609
581
  * |---|---|---|
@@ -615,47 +587,34 @@ export const jobResponse = z.object({
615
587
  * | `GET /api/robots/{id}/assets/sync/{syncId}` | developer | — |
616
588
  * | `POST /api/bridge/assets` | robot token, per sync | — |
617
589
  *
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.
590
+ * **The read routes are dual-mode.** `assets` is an *app-role* capability, and
591
+ * developers are not in any app's role system at all — two identity spaces, and
592
+ * a credential from one never authenticates the other. Reading the table as
593
+ * developer-only would mean an end user could never fetch a URDF, which is the
594
+ * audience the asset store exists for.
625
595
  *
626
596
  * 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.
597
+ * user reaches it when their role grants `assets`.
631
598
  *
632
599
  * **The rewritten mesh URIs in a served URDF are absolute, not
633
600
  * root-relative.** A relative URL resolves against *the consumer's* origin,
634
601
  * 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,
602
+ * 404 against the customer's own site. **A URL handed to somebody else's
603
+ * browser must never be relative to ours.**
604
+ *
605
+ * **There is no per-asset `DELETE`, and its absence is the design.** Symmetry
606
+ * is not a reason. Assets are immutable and content-addressed, and the
607
+ * operation a developer actually performs is *the URDF changed, sync again*: a
608
+ * **re-sync reconciles**, so assets the new URDF no longer references stop
609
+ * belonging to that robot. One mechanism instead of two, and robot deletion is
610
+ * already a cascade.
611
+ *
612
+ * Reading is a role capability; **changing the store is Owner-tier** — a sync
613
+ * spends the org's asset quota and a deletion breaks every app rendering that
614
+ * robot, so neither is a Member's to do.
615
+ *
616
+ * **`GET .../assets/missing` is the sole producer of `asset_missing`.** It
617
+ * never succeeds,
659
618
  * and that is what it is for: when the served URDF is rewritten, a reference
660
619
  * the store cannot answer has to be rewritten into *something*, and a URL that
661
620
  * 404s `asset_missing` naming the reference is the only option that leaves the
@@ -684,8 +643,8 @@ export const jobResponse = z.object({
684
643
  * credential over HTTP**. Everything the bridge does today goes over the
685
644
  * WebSocket, so this is new surface, not a variation of something existing —
686
645
  * 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.
646
+ * where a rate limit and a size ceiling matter most, and where the general
647
+ * rule applies hardest: the refusal must precede the work, not follow it.
689
648
  *
690
649
  * The end-user links carry `app_identifier` because the page cannot act
691
650
  * without it: `clientPasswordResetRequest` requires it, and an end user is
@@ -693,17 +652,16 @@ export const jobResponse = z.object({
693
652
  * not enough, and a page that guesses the app is a page that guesses wrong.
694
653
  *
695
654
  * **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.
655
+ * the Fleetless console to reset a password is wrong — the page belongs to the
656
+ * app, and an app has no configured base URL to send them to yet. Until it
657
+ * does, the console hosts both, and the URL carries the app so that moving it
658
+ * later is a redirect rather than a redesign.
659
+ *
660
+ * **`DELETE /api/org/members/:id` is not a row deletion.** A token minted
661
+ * before the removal must stop working; sessions are revoked for the subject.
704
662
  */
705
663
  /**
706
- * What a `rate_limited` refusal tells the caller (W6c).
664
+ * What a `rate_limited` refusal tells the caller.
707
665
  *
708
666
  * One number, and it is the only one that matters: **when to come back.** A
709
667
  * limit that says "too many" without saying "in 800 ms" produces a client that
@@ -719,11 +677,10 @@ export const rateLimitDetails = z.object({
719
677
  });
720
678
  /**
721
679
  * 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).
680
+ * `started_at`, with `seq` as the tiebreaker**.
723
681
  *
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:
682
+ * The tiebreaker is named rather than left implicit, because it matters twice
683
+ * over:
727
684
  *
728
685
  * 1. **`started_at` alone is not a total order.** Two jobs minted in the same
729
686
  * millisecond sorted against each other arbitrarily — differently on each
@@ -747,9 +704,9 @@ export const robotJobsResponse = z.object({
747
704
  /**
748
705
  * Every slug of a robot that a role can be granted, **with its kind**.
749
706
  *
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.
707
+ * A roles matrix built against the datapoint list alone cannot grant an
708
+ * action, a service or a publisher. One list that names every kind, with its
709
+ * kind, is what a matrix needs.
753
710
  */
754
711
  export const exposure = z.object({
755
712
  slug,
@@ -759,8 +716,8 @@ export const exposure = z.object({
759
716
  export const exposureListResponse = z.object({
760
717
  exposures: z.array(exposure),
761
718
  });
762
- /* ------------------------------------------------------------------ W5 --
763
- * Cameras (spec §10). Routes, written down as the W4 command routes are:
719
+ /* ------------------------------------------------------------------------
720
+ * Cameras. Routes, written down as the command routes are:
764
721
  *
765
722
  * | route | answers |
766
723
  * |---|---|
@@ -791,36 +748,27 @@ export const SNAPSHOT_HEADERS = {
791
748
  height: 'x-fleetless-height',
792
749
  };
793
750
  /**
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.
751
+ * The metadata an asset upload carries beside its raw body.
803
752
  *
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.
753
+ * Written here rather than left as a convention each side documents for
754
+ * itself. **A string shared by two implementations and defined in both is a
755
+ * string that drifts**, and a header is the easiest place for that to happen
756
+ * unnoticed: a zod schema cannot validate one, which is an argument for
757
+ * writing the names down once, not an argument for writing them down twice.
807
758
  *
808
759
  * `name` is the `package://` URI verbatim for a mesh — the same string
809
760
  * `asset.name` stores, and the same one `urdfCompleteness.missing` reports, so
810
761
  * a failed upload and a missing mesh can be matched by eye.
811
762
  */
812
763
  /**
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.
764
+ * **`name` travels percent-encoded in a second header.**
765
+ *
766
+ * HTTP header values are latin-1. A texture called `textures/日本語.png` cannot
767
+ * be put in one at all: in Python it raises a `UnicodeEncodeError` inside
768
+ * `urllib` — a `ValueError`, caught by neither `HTTPError` nor `URLError` — so
769
+ * a single non-ASCII filename can fail an entire sync with no cause on the
770
+ * wire. Non-ASCII names are ordinary rather than exotic, because `.dae`
771
+ * internal names come from 3D-authoring tools.
824
772
  *
825
773
  * The encoding is not invented here. **`GET .../assets/missing?name=` already
826
774
  * carries this exact string percent-encoded**, because a query parameter is
@@ -828,21 +776,19 @@ export const SNAPSHOT_HEADERS = {
828
776
  * answered.
829
777
  *
830
778
  * **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).
779
+ * detail.** Overloading `name` itself — the producer encodes, the store
780
+ * decodes — decodes identically for every name without a `%`, so an older
781
+ * producer and a newer store agree by luck right up until a name contains
782
+ * `%2f`, which the store would then silently turn into a `/`. A wire change
783
+ * whose breakage is invisible in the common case and silent in the uncommon
784
+ * one is the worst of both.
838
785
  *
839
786
  * So `name` keeps meaning exactly what it always meant, and `nameEncoded`
840
787
  * carries the percent-encoded UTF-8 form. **The store prefers `nameEncoded`
841
788
  * when present and uses `name` otherwise**, so:
842
789
  *
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;
790
+ * - a producer that sends only `name` behaves exactly as it always did;
791
+ * - a producer that sends both can carry a name latin-1 cannot express;
846
792
  * - no value is ever ambiguous about which encoding it is in.
847
793
  *
848
794
  * A producer that can send `nameEncoded` should send both, so a store older
@@ -855,23 +801,22 @@ export const ASSET_UPLOAD_HEADERS = {
855
801
  nameEncoded: 'x-fleetless-asset-name-encoded',
856
802
  syncId: 'x-fleetless-sync-id',
857
803
  /**
858
- * **Die angekündigte Größe, und sie ist der Grund, warum `asset_too_large`
859
- * überhaupt entstehen kann (W9b, DEF-116).**
804
+ * **The announced size, and it is what makes `asset_too_large` reachable at
805
+ * all.**
860
806
  *
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).
807
+ * A server-side body limit is applied by the content-type parser, before the
808
+ * handler runs, so an oversized upload can only be refused with a bare
809
+ * `413` carrying neither `limit_bytes` nor `size_bytes` — and the structured
810
+ * refusal `assetTooLargeDetails` describes would have no producer.
866
811
  *
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).
812
+ * With the size announced in a header the refusal can be made where it can
813
+ * say something: before a byte is buffered, with both numbers. It also lets
814
+ * a producer discover its own limit without first reading the whole file
815
+ * into memory.
871
816
  *
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.
817
+ * The header is an **announcement, not a proof**: a sender can lie. The
818
+ * ceiling still applies to the body — this does not replace enforcement, it
819
+ * only makes the refusal answerable.
875
820
  */
876
821
  size: 'x-fleetless-asset-size',
877
822
  };
@@ -906,7 +851,7 @@ export const cameraListResponse = z.object({
906
851
  * What a viewer needs to join, and **what it costs them to hold**.
907
852
  *
908
853
  * `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
854
+ * starts the robot publishing and the last release stops it. A client
910
855
  * that forgets to release keeps a robot streaming to nobody, so the SDK hands
911
856
  * back a `release()` rather than a bare token.
912
857
  *
@@ -922,16 +867,14 @@ export const cameraListResponse = z.object({
922
867
  */
923
868
  export const liveSessionResponse = z.object({
924
869
  /**
925
- * This viewer's hold, and the **only** thing `DELETE` should be given
926
- * (W6b).
870
+ * This viewer's hold, and the **only** thing `DELETE` should be given.
927
871
  *
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.
872
+ * A hold addressed by `{identity, robot, slug}` alone would make two tabs of
873
+ * one logged-in user a single hold as far as the refcount can see. Closing
874
+ * either tab would release it, and the surviving tab would keep its LiveKit
875
+ * connection — the token is checked at join and never again — rendering a
876
+ * video the robot had already stopped producing. A frozen picture is not an
877
+ * ended session.
935
878
  *
936
879
  * `DELETE` without a session id keeps today's meaning — *release my holds
937
880
  * on this camera* — because an SDK that has lost its id, or a client that
@@ -964,8 +907,8 @@ export const liveSessionResponse = z.object({
964
907
  * which polls continuously while a tab is open.
965
908
  *
966
909
  * `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
910
+ * indistinguishable from a live one, and snapshots are deliberately cheap and
911
+ * therefore deliberately old. `null` values mean nothing has been
969
912
  * captured yet — which is an answer, not an error.
970
913
  */
971
914
  export const snapshotMetaResponse = z.object({
@@ -987,31 +930,26 @@ export const snapshotMetaResponse = z.object({
987
930
  }),
988
931
  });
989
932
  // ---------------------------------------------------------------------------
990
- // W6 — retention, history and org quotas (§8, §12.4)
933
+ // Retention, history and org quotas
991
934
  // ---------------------------------------------------------------------------
992
935
  /**
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.
936
+ * **Both history shapes answer the same boundary the same way: `[from, to)`.**
937
+ *
938
+ * Half-open, because it is the only rule under which **adjacent windows tile
939
+ * without overlap**: `[0,10)` then `[10,20)` covers every instant once. With an
940
+ * inclusive upper bound a sample at exactly `10` belongs to both windows, and
941
+ * any consumer summing them counts it twice.
942
+ *
943
+ * Two shapes that answered it differently would give opposite results for a
944
+ * point landing exactly on `to` — same range, same data — and the difference
945
+ * renders as a gap in one of the two.
946
+ *
947
+ * This is a statement about behaviour, not a field: nothing in the shapes below
948
+ * can enforce it. It is written here because this is the one place both shapes
949
+ * are defined together.
1012
950
  */
1013
951
  /**
1014
- * A history query (§8). `from`/`to` accept **either** a relative expression
952
+ * A history query. `from`/`to` accept **either** a relative expression
1015
953
  * (`now-30s`, `now-5m`, `now-1h`) **or** absolute unix milliseconds, because
1016
954
  * a chart asks the first way and a report asks the second, and making a
1017
955
  * client convert is making it guess our clock.
@@ -1037,26 +975,23 @@ export const historyQuery = z.object({
1037
975
  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
976
  }),
1039
977
  /**
1040
- * **A union whose input branch IS the wire, not a coercion (W9d, DEF-059).**
978
+ * **A union whose input branch IS the wire, not a coercion.**
1041
979
  *
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
980
+ * The schema describes a **query string**, where every value arrives as
981
+ * text. `z.coerce.number()` would read it, but a coercion cannot be
982
+ * *published*: zod renders a coercion's **result** in either `io` mode, so
983
+ * input and output both emit `{"type":"integer"}` — an artifact describing a
1048
984
  * shape a query string can never carry. Anyone validating a real request
1049
985
  * against it rejects every one that sets `limit`.
1050
986
  *
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.
987
+ * That is a different problem from `.default()` publishing as required,
988
+ * which input-mode export genuinely does fix.
1054
989
  *
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
990
+ * A union states both truths honestly: the wire carries a numeric string, a
991
+ * programmatic caller may pass a number, and the artifact can render the
1057
992
  * input branch because there is one to render.
1058
993
  *
1059
- * **What the artifact no longer says, named here rather than left silent.**
994
+ * **What the artifact does not say, named here rather than left silent.**
1060
995
  * The `1..10000` bound lives in the `.pipe()`, which is the *output* half, so
1061
996
  * no input-mode artifact can express it as a constraint: the published shape
1062
997
  * is `^\d{1,5}$` or a bare integer, and five digits is a weak echo of the
@@ -1064,18 +999,17 @@ export const historyQuery = z.object({
1064
999
  * parsing, not by the shape of the text — but it is a **reduction**, and an
1065
1000
  * artifact that stops naming a bound reads as if there were none.
1066
1001
  *
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.
1002
+ * So both branches carry the number in a `.describe()`. It is **not** a
1003
+ * constraint and nothing validates against it; it means a generator, or a
1004
+ * person reading only the published schema, sees the actual ceiling instead
1005
+ * of nothing. The gap is narrowed and named rather than closed.
1072
1006
  */
1073
1007
  limit: z
1074
1008
  .union([
1075
1009
  z
1076
1010
  .string()
1077
1011
  .regex(/^\d{1,5}$/)
1078
- // **The description carries the number the shape cannot** (Nimbus-W9d's
1012
+ // **The description carries the number the shape cannot** (see the
1079
1013
  // proposal). Five digits is the regex's bound, not the contract's; the
1080
1014
  // real ceiling lives in the `.pipe()` below and therefore cannot appear
1081
1015
  // in an input-mode artifact. This does not close that gap and does not
@@ -1095,7 +1029,7 @@ export const historyQuery = z.object({
1095
1029
  }),
1096
1030
  });
1097
1031
  /**
1098
- * Raw samples. `timestamp_ms` is the **bridge's capture time** (§6.3) — the
1032
+ * Raw samples. `timestamp_ms` is the **bridge's capture time** — the
1099
1033
  * same instant the live value carried, so a recorded point and a live one can
1100
1034
  * be placed on one axis without apology.
1101
1035
  *
@@ -1149,9 +1083,8 @@ export const historySamplesResponse = z.object({
1149
1083
  * inspection.
1150
1084
  *
1151
1085
  * `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.
1086
+ * zero are different facts. When two facts share one representation, a chart
1087
+ * is the easiest place to draw a gap as a line.
1155
1088
  */
1156
1089
  export const historyBucketsResponse = z.object({
1157
1090
  slug: slug.meta({ description: 'The datapoint these buckets summarise.' }),
@@ -1233,7 +1166,7 @@ export const historyBucketsResponse = z.object({
1233
1166
  */
1234
1167
  export const historyResponse = z.union([historySamplesResponse, historyBucketsResponse]);
1235
1168
  /**
1236
- * W6a — deletion, and the one channel that reports health.
1169
+ * Deletion, and the one channel that reports health.
1237
1170
  *
1238
1171
  * | Route | Body | Answer |
1239
1172
  * |---|---|---|
@@ -1259,24 +1192,17 @@ export const historyResponse = z.union([historySamplesResponse, historyBucketsRe
1259
1192
  * takes an optional `robot_id` filter rather than living at a per-robot
1260
1193
  * path.
1261
1194
  *
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.
1195
+ * The per-robot reading is the tempting one and it is wrong: a health list is
1196
+ * rendered for every robot at once, and a per-robot path makes that N requests
1197
+ * to draw one screen — while the event that keeps it fresh arrives org-wide
1198
+ * anyway. A snapshot and a channel that disagree about scope are not two halves
1199
+ * of one thing; they are two things every consumer has to reconcile, separately,
1200
+ * forever.
1201
+ *
1202
+ * So: same scope, one route, and `?robot_id=` for the narrow question.
1277
1203
  */
1278
1204
  /**
1279
- * What a `robot.deleted` audit event carries (W6a).
1205
+ * What a `robot.deleted` audit event carries.
1280
1206
  *
1281
1207
  * A deletion record that says only *that* something was destroyed is a
1282
1208
  * receipt for an unknown amount. This names it: how many configured slugs,
@@ -1296,8 +1222,7 @@ export const robotDeletionSummary = z.object({
1296
1222
  * console renders them in one sentence: *"this deletes N published slugs …
1297
1223
  * and M cameras"*. With cameras inside `slug_count` that sentence counts
1298
1224
  * 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).
1225
+ * irreversible click destroys.
1301
1226
  *
1302
1227
  * A draft is destroyed too and is described by `had_unpublished_draft`
1303
1228
  * rather than by either of these: describing three things with two numbers
@@ -1308,7 +1233,7 @@ export const robotDeletionSummary = z.object({
1308
1233
  bytes_freed: z.number().int().nonnegative(),
1309
1234
  cameras: z.array(slug),
1310
1235
  /**
1311
- * Assets destroyed with the robot (W7), and **`asset_bytes_freed` is what
1236
+ * Assets destroyed with the robot, and **`asset_bytes_freed` is what
1312
1237
  * this org actually gets back** — not the sum of the assets' sizes.
1313
1238
  *
1314
1239
  * Storage is content-addressed, so a mesh two robots share survives the
@@ -1392,17 +1317,16 @@ export const robotDeleteQuery = z
1392
1317
  })
1393
1318
  .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
1319
  /**
1395
- * The seven health states, declared **once** (W6a review).
1320
+ * The seven health states, declared **once**.
1396
1321
  *
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.
1322
+ * `resourceHealthState` and `resourceHealthEvent` are the snapshot and the push
1323
+ * of the same thing. Writing the values out twice publishes two independent
1324
+ * artifacts with no `$ref` between them, kept in step only by whoever
1325
+ * remembers to edit both.
1402
1326
  *
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.
1327
+ * One concept rendering as two artifacts that nothing keeps in step is its own
1328
+ * class of artifact-versus-source defect, distinct from `.default()` publishing
1329
+ * as `required` and from a coercion's unrepresentable input.
1406
1330
  */
1407
1331
  export const RESOURCE_HEALTH_STATES = [
1408
1332
  'ok',
@@ -1414,7 +1338,7 @@ export const RESOURCE_HEALTH_STATES = [
1414
1338
  'unreadable_credential',
1415
1339
  /**
1416
1340
  * A camera names a credential that **does not exist** in this org — deleted,
1417
- * mistyped, or belonging to somebody else (W6a review).
1341
+ * mistyped, or belonging to somebody else.
1418
1342
  *
1419
1343
  * Separate from `unreadable_credential` because that one asserts a
1420
1344
  * decryption that was attempted and failed, and here nothing was ever
@@ -1424,11 +1348,11 @@ export const RESOURCE_HEALTH_STATES = [
1424
1348
  * — a different fact with a different fix.
1425
1349
  *
1426
1350
  * **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.
1351
+ * against. Its one producer tolerated an unresolved `credentials_ref` at
1352
+ * publish time; that field is gone, so nothing emits this today. It is kept
1353
+ * only until the credential store is removed, which takes these three
1354
+ * credential states with it — `unreadable_credential` and the `readable` fact
1355
+ * on `credentialSummary` go the same way.
1432
1356
  */
1433
1357
  'credential_missing',
1434
1358
  /** A configuration change stopped this stream, deliberately. */
@@ -1449,26 +1373,22 @@ export const RESOURCE_HEALTH_STATES = [
1449
1373
  ];
1450
1374
  /**
1451
1375
  * 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.
1376
+ * sees it.
1377
+ *
1378
+ * It exists because nothing else carries the state of a camera, a source or a
1379
+ * credential to a developer who is not, at that exact moment, pressing a
1380
+ * button. A publish failure after the `201` reaches no one; a source whose
1381
+ * password is wrong fails at config-apply time with nobody watching and stays
1382
+ * silent until someone presses "Go live" days later; a viewer cannot learn
1383
+ * *why* a stream ended; an undecryptable credential reports as healthy.
1384
+ *
1385
+ * One shape rather than a field per symptom, because a failure written into a
1386
+ * state field the platform writes and reads in one place is a dead end.
1387
+ *
1388
+ * `reason` is for a human and is **never** built from an exception message: a
1389
+ * camera password reaches a log that way, and an exception message from a
1390
+ * failing stream travels to the cloud on this very path. Type names and fixed
1391
+ * strings only.
1472
1392
  */
1473
1393
  export const resourceHealthState = z.object({
1474
1394
  robot_id: z.uuid(),
@@ -1476,18 +1396,17 @@ export const resourceHealthState = z.object({
1476
1396
  /** The camera slug, or the credential name. */
1477
1397
  ref: z.string().min(1).max(64),
1478
1398
  /**
1479
- * **Which of two questions this entry answers (W9a, DEF-072).**
1399
+ * **Which of two questions this entry answers.**
1480
1400
  *
1481
1401
  * `'source'` — can the source be read at all? (`unreachable`, `auth_failed`,
1482
1402
  * `unreadable_credential`, `missing_credential`, `ok`, …)
1483
1403
  * `'publish'` — given a readable source, did publishing to LiveKit work?
1484
1404
  *
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.
1405
+ * Without the facet both answers land in one entry keyed
1406
+ * `${robot} ${kind} ${ref}` with one flat `state`, in which `publish_failed`
1407
+ * answers *"can we publish"* and every other value answers *"can the source
1408
+ * be read"* — same key, same field, two questions, each overwriting the
1409
+ * other.
1491
1410
  *
1492
1411
  * The facet is part of the entry's identity: a camera can perfectly well be
1493
1412
  * readable and unpublishable at the same moment, and that pair is exactly
@@ -1507,14 +1426,8 @@ export const resourceHealthState = z.object({
1507
1426
  /**
1508
1427
  * The current state of everything in the **org**.
1509
1428
  *
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
1429
+ * Org-wide, not per robot — the route table above explains why. A channel
1430
+ * with no snapshot cannot answer "what is the state now?" for a
1518
1431
  * page that just loaded — it can only report the next change, which may be
1519
1432
  * hours away. Both halves or neither.
1520
1433
  */
@@ -1536,9 +1449,9 @@ export const orgHealthQuery = z
1536
1449
  })
1537
1450
  .meta({ description: 'The optional robot filter of `GET /api/org/health`.' });
1538
1451
  /**
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.
1452
+ * Org protection quotas — generous, server-side adjustable, visible in
1453
+ * settings. Protection against runaway use, not a business model; a later one
1454
+ * docks onto the same dials.
1542
1455
  */
1543
1456
  export const orgQuotas = z.object({
1544
1457
  max_robots: z.number().int().positive(),
@@ -1548,30 +1461,28 @@ export const orgQuotas = z.object({
1548
1461
  max_retention_writes_per_minute: z.number().int().nonnegative(),
1549
1462
  max_realtime_connections: z.number().int().positive(),
1550
1463
  /**
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.
1464
+ * Asset storage — **its own dial, not part of `max_retention_bytes`.** A
1465
+ * sync grows storage in jumps and time series grow steadily; one dial would
1466
+ * let the first crowd out the second, and the org that hit its limit would be
1467
+ * told to look at the wrong thing.
1555
1468
  *
1556
1469
  * **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.
1470
+ * not per object the platform stores on its behalf.** The two readings are
1471
+ * indistinguishable from the number alone and a customer is entitled to know
1472
+ * which one they are being charged for.
1560
1473
  *
1561
1474
  * Within an org, sharing is free: two robots referencing the same mesh cost
1562
1475
  * one copy, which is what dedup means to a customer, and anything else
1563
1476
  * charges an org twice for a fleet of identical robots — the normal case.
1564
1477
  *
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.
1478
+ * **Across orgs, sharing is not free.** Storage stays globally
1479
+ * content-addressed (one object per sha256; that efficiency is real), but
1480
+ * accounting is per-org: an org is charged for each distinct blob it
1481
+ * references and credited when its own last reference goes, whether or not
1482
+ * the blob survives for somebody else. Global refcounting would make the
1483
+ * first org to sync a blob pay for it forever while every later org stored it
1484
+ * free — a quota evadable by anyone whose mesh someone else had already
1485
+ * uploaded, and an org's own number would depend on who got there first.
1575
1486
  */
1576
1487
  max_asset_storage_bytes: z.number().int().nonnegative(),
1577
1488
  });
@@ -1583,8 +1494,7 @@ export const orgQuotas = z.object({
1583
1494
  * `positive()` because a quota of zero would forbid everything, but a
1584
1495
  * **usage** of zero is the honest answer for every org on the day it signs
1585
1496
  * 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.
1497
+ * letting an empty bucket and a zero average share a representation.
1588
1498
  *
1589
1499
  * Every field is optional because a quota we do not measure must be
1590
1500
  * **absent**, never reported as `0` — "not measured" and "measured as zero"
@@ -1625,11 +1535,9 @@ export const BRIDGE_LATENCY_RETENTION_DAYS = 7;
1625
1535
  * | `GET /api/org/latency` | `orgLatencyQuery` | `orgLatencyResponse` — one series per robot, truncation named |
1626
1536
  * | `GET /api/robots/:id/jobs/history` | `jobRunQuery` | `jobRunListResponse` — the same read, robot-scoped, developers **and** clients |
1627
1537
  *
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.
1538
+ * The paths are written down beside the shapes, as in
1539
+ * `robotDeletionSummary`'s neighbouring table: a shape whose route is not
1540
+ * named here is a fact that lives only in somebody's memory.
1633
1541
  *
1634
1542
  * Three things about them are worth stating rather than inferring:
1635
1543
  *
@@ -1721,7 +1629,7 @@ export const robotLatencySeries = z.object({
1721
1629
  export const orgLatencyQuery = z
1722
1630
  .object({
1723
1631
  from_ms: wireTimestampMs,
1724
- /** Exclusive — half-open `[from, to)`, the convention every other query here already follows (DEF-062). */
1632
+ /** Exclusive — half-open `[from, to)`, the convention every other query here already follows. */
1725
1633
  to_ms: wireTimestampMs,
1726
1634
  /**
1727
1635
  * One robot's own sparkline. `z.uuid()`, because the column is one —
@@ -1775,7 +1683,7 @@ export const orgLatencyResponse = z.object({
1775
1683
  */
1776
1684
  export const USAGE_WINDOW_MAX_DAYS = 366;
1777
1685
  /**
1778
- * The five things the meter records (spec D1).
1686
+ * The five things the meter records.
1779
1687
  *
1780
1688
  * Storage is two metrics and not one summed byte count, for
1781
1689
  * `org_quotas.max_asset_storage_bytes`'s own reason applied to billing: a sync
@@ -1816,7 +1724,7 @@ export const usageDay = z
1816
1724
  }, { message: 'must be a UTC calendar day, YYYY-MM-DD' });
1817
1725
  /**
1818
1726
  * **The window is inclusive at both ends**, unlike every millisecond window in
1819
- * this file (`from_ms`/`to_ms`, half-open per DEF-062).
1727
+ * this file (`from_ms`/`to_ms`, which are half-open).
1820
1728
  *
1821
1729
  * That inconsistency is deliberate and is stated here rather than left to be
1822
1730
  * discovered: a calendar day is a unit, not an instant, and a person asking for
@@ -1855,7 +1763,7 @@ export const orgUsageQuery = z
1855
1763
  /**
1856
1764
  * One day's reading for one metric.
1857
1765
  *
1858
- * **`app_id` is `null` when the consumer is the org itself** (spec D2), and
1766
+ * **`app_id` is `null` when the consumer is the org itself**, and
1859
1767
  * what that `null` means for billing depends on the *metric*, not on
1860
1768
  * `app_id` alone. `api_calls` and `live_session_ms` are attributable to an
1861
1769
  * app: a `null` app_id on those two is the developer console's own traffic,
@@ -1884,9 +1792,8 @@ export const orgUsageQuery = z
1884
1792
  * not hold at all: everything counted since the last successful flush is
1885
1793
  * held in memory, deliberately uncapped, and a `kill -9` loses all of it.
1886
1794
  * 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.
1795
+ * the worse half of it), but "at most one interval" describes a platform whose
1796
+ * writes are landing, not a guarantee that survives an outage.
1890
1797
  *
1891
1798
  * A row the database rejects **permanently** — most concretely one whose org
1892
1799
  * has been deleted since the count, since a usage row's `org_id` is `ON
@@ -1949,11 +1856,10 @@ export const renameSlugResponse = z.object({
1949
1856
  * `GET /api/robots/:id/config/slug-usage/:slug` — what a rename would touch;
1950
1857
  * feeds the console's confirm dialog.
1951
1858
  *
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.
1859
+ * `alert_count` is part of the atomic rename transaction alongside grants and
1860
+ * history: alerts are keyed by `(robot_id, slug)` too, and a rename that
1861
+ * silently moved the alert row while the usage preview stayed silent about it
1862
+ * would show a developer a smaller blast radius than the rename actually has.
1957
1863
  */
1958
1864
  export const slugUsageResponse = z.object({
1959
1865
  grant_count: z.number().int().nonnegative(),