@tikoci/centrs 0.1.8 → 0.1.9-next.22

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 (80) hide show
  1. package/CHANGELOG.md +216 -31
  2. package/GLOSSARY.txt +8 -0
  3. package/README.md +16 -8
  4. package/SECURITY.md +11 -3
  5. package/commands/api/README.md +44 -15
  6. package/commands/api/examples.md +44 -6
  7. package/commands/execute/README.md +218 -7
  8. package/commands/execute/examples.md +278 -1
  9. package/commands/explain/README.md +2 -1
  10. package/commands/mcp/README.md +11 -0
  11. package/commands/mcp/examples.md +6 -0
  12. package/commands/retrieve/README.md +32 -14
  13. package/commands/retrieve/examples.md +10 -6
  14. package/commands/transfer/examples.md +9 -0
  15. package/docs/CLI.md +10 -8
  16. package/docs/CONSTITUTION.md +54 -3
  17. package/docs/MATRIX.md +20 -3
  18. package/docs/errors/cdb/empty-group.md +19 -3
  19. package/docs/errors/cdb/empty-selection.md +9 -3
  20. package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
  21. package/docs/errors/cmr/cancelled.md +15 -0
  22. package/docs/errors/cmr/client-disconnected.md +19 -0
  23. package/docs/errors/cmr/client-script-not-validated.md +22 -0
  24. package/docs/errors/cmr/console-only.md +29 -0
  25. package/docs/errors/cmr/empty-selection.md +33 -0
  26. package/docs/errors/cmr/membership-changed.md +20 -0
  27. package/docs/errors/cmr/not-a-controller.md +26 -0
  28. package/docs/errors/cmr/outcome-unknown.md +29 -0
  29. package/docs/errors/cmr/output-truncated.md +16 -0
  30. package/docs/errors/cmr/policy-denied.md +31 -0
  31. package/docs/errors/cmr/rule-selects-every-device.md +32 -0
  32. package/docs/errors/cmr/selection-includes-controller.md +27 -0
  33. package/docs/errors/cmr/selection-incomplete.md +18 -0
  34. package/docs/errors/cmr/validation-incomplete.md +23 -0
  35. package/docs/errors/routeros/version-unsupported.md +4 -0
  36. package/docs/errors/transport/write-outcome-unknown.md +24 -0
  37. package/docs/errors/usage/flag-after-separator.md +34 -0
  38. package/docs/errors/usage/missing-labels.md +19 -0
  39. package/package.json +7 -5
  40. package/src/api-fanout.ts +18 -2
  41. package/src/api.ts +230 -71
  42. package/src/btest.ts +7 -2
  43. package/src/cli/api.ts +30 -16
  44. package/src/cli/common.ts +180 -1
  45. package/src/cli/devices.ts +0 -2
  46. package/src/cli/discover.ts +0 -1
  47. package/src/cli/execute.ts +176 -21
  48. package/src/cli/explain.ts +2 -0
  49. package/src/cli/mcp.ts +0 -1
  50. package/src/cli/retrieve.ts +40 -18
  51. package/src/cli/settings.ts +0 -3
  52. package/src/cli/terminal.ts +0 -2
  53. package/src/cli/transfer.ts +10 -8
  54. package/src/cli/version.ts +87 -0
  55. package/src/cli.ts +5 -3
  56. package/src/core/cmr-fleet.ts +486 -0
  57. package/src/core/cmr.ts +182 -0
  58. package/src/core/envelope.ts +46 -1
  59. package/src/core/error-catalog.ts +95 -2
  60. package/src/core/execute-command.ts +144 -19
  61. package/src/core/fanout.ts +207 -14
  62. package/src/errors.ts +3 -1
  63. package/src/execute-cmr.ts +1442 -0
  64. package/src/execute-fanout.ts +15 -1
  65. package/src/execute.ts +173 -40
  66. package/src/index.ts +35 -1
  67. package/src/mcp/safety.ts +11 -0
  68. package/src/mcp/tools.ts +4 -1
  69. package/src/protocols/adapter.ts +54 -12
  70. package/src/resolver/cdb.ts +13 -1
  71. package/src/resolver/index.ts +4 -0
  72. package/src/resolver/selection.ts +37 -1
  73. package/src/resolver/target.ts +96 -8
  74. package/src/retrieve-fanout.ts +4 -1
  75. package/src/retrieve-follow.ts +29 -33
  76. package/src/retrieve-sample.ts +14 -8
  77. package/src/retrieve-stream.ts +38 -6
  78. package/src/retrieve.ts +73 -59
  79. package/src/transfer-fanout.ts +8 -1
  80. package/src/transfer.ts +15 -19
@@ -262,13 +262,19 @@ target only: a fan-out selector fails `usage/fanout-not-supported`. Follow is
262
262
  filtered with the same `--query`/`--filter` grammar as a one-shot read (#397).
263
263
 
264
264
  **What each line means.** Every line is one envelope; read
265
- `meta.operation.stream`:
265
+ `meta.operation.stream`. Only `started`, `synced` and `summary` carry the full
266
+ `meta` (target, settings, validation, request, auth). A `frame` line carries
267
+ only `meta.operation` (`kind`, `objectCount`, `stream`), because the rest is
268
+ the same on every line. An error line is a full error envelope: the one line
269
+ of a follow that fails before it starts (resolution, validation, a
270
+ non-followable menu, a non-native transport), and a failed `summary`
271
+ ([constitution](../../docs/CONSTITUTION.md#result-envelope)).
266
272
 
267
273
  | `kind` | When | `data` | Fields |
268
274
  | ------ | ---- | ------ | ------ |
269
- | `frame` | One row change | the row (`upsert`), `null` (`removed`) | `index`, `phase` (`snapshot`/`live`), `change` (`upsert`/`removed`), `id`, `source` (`print`/`listen`/`sweep`/`membership`) |
275
+ | `frame` | One row change | the row (`upsert`), `null` (`removed`) | `index`, `observedAt`, `phase` (`snapshot`/`live`), `change` (`upsert`/`removed`), `id`, `source` (`print`/`listen`/`sweep`/`membership`) |
276
+ | `started` | First, once validation passed | `null` | — (`tips` carries any advice) |
270
277
  | `synced` | Once, after the snapshot | `null` | `rows`: rows held at that point |
271
- | `notice` | First, only when there is advice | `null` | — (`tips` carries it) |
272
278
  | `summary` | Last, exactly once | the counts | `stopReason`, `frames`, `snapshot`, `changes`, `sweeps`, `synced`, `durationMs` |
273
279
 
274
280
  - **Bootstrap (A1).** centrs starts the `listen`, then the `print`, on one
@@ -279,6 +285,11 @@ filtered with the same `--query`/`--filter` grammar as a one-shot read (#397).
279
285
  order resurrects deleted rows (#396 round 3). `synced` means the bootstrap
280
286
  was processed. It is not an atomic snapshot, a full event history, or
281
287
  routing convergence.
288
+ - **`observedAt`** (ISO 8601, host clock) is when centrs received the reply
289
+ the frame is built from: the `listen` change, the snapshot row, the
290
+ membership read or the sweep. A bootstrap change replayed after the
291
+ snapshot keeps its arrival time, so `observedAt` is not ordered by `index`.
292
+ A slow reader does not move it.
282
293
  - **`upsert`** carries the latest full row; replace what you hold for `id`.
283
294
  **`removed`** carries `data: null`; drop `id`. The row identity is always
284
295
  `meta.operation.stream.id`, even when `--attributes` leaves `.id` out of
@@ -297,8 +308,8 @@ filtered with the same `--query`/`--filter` grammar as a one-shot read (#397).
297
308
  sweep only checks membership: it does not repair a missed field update or
298
309
  report rows `listen` never announced. `--sweep` needs a unit (`10s`,
299
310
  `500ms`); a bare number fails `settings/invalid-timeout`, as for `--sample`.
300
- `--sweep 0` turns it off, with a notice tip saying view-menu removals will
301
- not be reported.
311
+ `--sweep 0` turns it off, with a tip on `started` saying view-menu removals
312
+ will not be reported.
302
313
  - **What `listen` never reports.** Hot counters (`rx-byte`, firewall
303
314
  `bytes`/`packets`) and some protocol state (BFD sessions accept `listen` but
304
315
  stay silent). The follow is correct but quiet there; `--sample` (below)
@@ -320,7 +331,8 @@ filtered with the same `--query`/`--filter` grammar as a one-shot read (#397).
320
331
  indefinitely or postpone the first sweep and live `--count`. This may
321
332
  coalesce more changes than unfiltered A1 replay.
322
333
  The filtered sweep also detects silent exits from a predicate (e.g. a
323
- decreasing address-list `timeout`). It does not discover silent entries or
334
+ decreasing address-list `timeout`), reported as `removed` with
335
+ `source: "sweep"`. It does not discover silent entries or
324
336
  repair silent field updates: use `--sample` for such state. With `--sweep 0`,
325
337
  silent exits remain held until a listen notification arrives.
326
338
  - **Projection.** `--attribute(s)` projects `data`; centrs still asks RouterOS
@@ -346,9 +358,11 @@ new snapshot. `--sweep` without `--follow`, and `--count` or `--duration`
346
358
  without `--follow` or `--sample`, fail `usage/conflicting-flags`.
347
359
 
348
360
  **Format.** Under `--follow`, `json`, `yaml` and `ndjson` all print one compact
349
- envelope per line (as `api --stream` does); `text` prints one short row per
350
- line. Every envelope, errors included, goes to stdout, and the exit code is
351
- nonzero for any failure, even after rows. Without `--follow`,
361
+ envelope per line (as `api --stream` does), errors included, on stdout.
362
+ `text` prints one short row per `frame` on stdout; the `started` tips, the
363
+ `synced` and summary lines, warnings and errors go to stderr, since text
364
+ stdout is data ([constitution](../../docs/CONSTITUTION.md#result-envelope)).
365
+ The exit code is nonzero for any failure, even after rows. Without `--follow`,
352
366
  `--format ndjson` prints the ordinary envelope as one compact line; it never
353
367
  starts a follow.
354
368
 
@@ -387,10 +401,13 @@ stays available as `api <router> <menu>/print -X POST -f interval=1s --stream`
387
401
  (`commands/api/README.md`).
388
402
 
389
403
  **What each line means.** Every line is one envelope; read
390
- `meta.operation.stream`:
404
+ `meta.operation.stream`. `started` and `summary` carry the full `meta`; a
405
+ `sample` line carries only `meta.operation` (`kind`, `objectCount`, `stream`),
406
+ as a follow `frame` does:
391
407
 
392
408
  | `kind` | When | `data` | Fields |
393
409
  | ------ | ---- | ------ | ------ |
410
+ | `started` | First, once validation passed | `null` | — |
394
411
  | `sample` | One complete read | the whole read: a row array (list), the record (singleton), or the bare value (one `--attribute` on a singleton) | `index` (1-based), `at` (when the read was sent, host clock, ISO 8601), `readMs` |
395
412
  | `summary` | Last, exactly once | the counts | `stopReason`, `samples`, `durationMs` |
396
413
 
@@ -415,9 +432,9 @@ carrying the partial `samples` count; there is no retry. `--list-attributes`, `-
415
432
  `--sweep` conflict with `--sample` (`usage/conflicting-flags`).
416
433
 
417
434
  **Format.** As for `--follow`: `json`, `yaml` and `ndjson` print one compact
418
- envelope per line, and `text` prints `index`, `at` and the data on one line.
419
- Every envelope, errors included, goes to stdout; the exit code is nonzero for
420
- any failure, even after samples.
435
+ envelope per line, errors included, on stdout. `text` prints `index`, `at` and
436
+ the data on one stdout line per sample, and the summary, warnings and errors
437
+ on stderr. The exit code is nonzero for any failure, even after samples.
421
438
 
422
439
  Recipes (bounded, so an agent can run them unattended):
423
440
 
@@ -513,7 +530,8 @@ retrieve-specific behavior on top of the shared engine:
513
530
  `transport/connection-closed` (REST 5xx is mapped to
514
531
  `transport/connection-closed`). It does **not** retry `routeros/*`,
515
532
  `validation/*`, `auth/*`, `cdb/*`, `target/*`, timeouts, DNS, TLS, or refused
516
- connections.
533
+ connections. (`execute` and `api` share this engine but never replay a write
534
+ that was sent.)
517
535
 
518
536
  ## Definition of done
519
537
 
@@ -434,7 +434,9 @@ centrs retrieve $A /ip/address --follow --duration 3s --port $API_PORT --usernam
434
434
  Every line parses as an envelope. Frames before `synced` have
435
435
  `phase: "snapshot"` and `source: "print"`; exactly one `synced`; the last line
436
436
  is the summary with `stopReason: "duration-elapsed"`, `synced: true`,
437
- `changes: 0`. Every frame carries `meta.operation.stream.id`.
437
+ `changes: 0`. Every frame carries `meta.operation.stream.id` and an ISO 8601
438
+ `observedAt`, and no other meta; `synced` and the summary carry the full
439
+ `meta` (`target`, `via`, `settings`, …).
438
440
 
439
441
  ### FL2. A live change, and `--count` that ignores the snapshot
440
442
 
@@ -454,7 +456,8 @@ centrs retrieve $A /ip/address --follow --sweep 0 --count 1 --duration 15s --por
454
456
 
455
457
  After `synced`, remove an address that existed before the follow. One `live`
456
458
  `removed` frame, `source: "listen"`, `data: null`. `--sweep 0` keeps the sweep
457
- out of the race, and its `tip/follow-sweep-off` notice is the first line.
459
+ out of the race, and its `tip/follow-sweep-off` tip rides the first line,
460
+ `started`.
458
461
 
459
462
  ### FL4. A removal RouterOS does not send (view menu → sweep)
460
463
 
@@ -529,9 +532,10 @@ centrs retrieve $R /ip/address --sample 1s --count 3 --username $U --password $P
529
532
  centrs retrieve $A /ip/address --via native-api --sample 1s --count 3 --port $API_PORT --username $U --password $P --json
530
533
  ```
531
534
 
532
- Each run prints three `sample` lines (`index` 1–3, `data` a row array) and a
533
- summary with `stopReason: "count-reached"`, `samples: 3`. Consecutive `at`
534
- values are at least one second apart. `meta.via` is the transport used.
535
+ Each run prints `started`, three `sample` lines (`index` 1–3, `data` a row
536
+ array) and a summary with `stopReason: "count-reached"`, `samples: 3`.
537
+ Consecutive `at` values are at least one second apart. The `started` line's
538
+ `meta.via` is the transport used.
535
539
 
536
540
  ### SA2. Counters `listen` never reports
537
541
 
@@ -687,7 +691,7 @@ centrs retrieve $A /ip/firewall/address-list --via native-api --port $API_PORT -
687
691
 
688
692
  The harness adds a row with a 10-second timeout. A sweep removes it from the
689
693
  selected set as its remaining timeout crosses 8 seconds, with
690
- `source: "membership"`; the row still exists in an unfiltered read.
694
+ `source: "sweep"`; the row still exists in an unfiltered read.
691
695
 
692
696
  ### FQ4. CLI alias and predicate validation
693
697
 
@@ -463,6 +463,15 @@ centrs transfer --group no-such-group list --via rest --cdb-file $CDB --json
463
463
  `ok: true`, `data.summary = { total: 0, ok: 0, failed: 0 }`,
464
464
  `warnings` include `cdb/empty-group`. Exit `0`.
465
465
 
466
+ A write verb (`upload`, `remove`, `mkdir`, `copy`) over the same group is an
467
+ outer error, with or without `--yes` (#460):
468
+
469
+ ```bash
470
+ centrs transfer --group no-such-group remove fanout-test.txt --via rest --cdb-file $CDB --yes --json
471
+ ```
472
+
473
+ `ok: false`, `error.code` `cdb/empty-group`. Exit `1`.
474
+
466
475
  ### F3. `--where` device-class selector (subset)
467
476
 
468
477
  ```bash
package/docs/CLI.md CHANGED
@@ -51,8 +51,8 @@ Usage: centrs retrieve <target> <routeros-path> [flags] | centrs retrieve <targe
51
51
  | `--all-attributes` | | Request the RouterOS detail/all-attributes shape. |
52
52
  | `--list-attributes / --list` | | List inspect-derived attributes without running the data call. |
53
53
  | `--query / --filter` | `<expr>` | Row filter written as after `print where` (`disabled`, `mtu>=1500 and !dynamic`), run on the router as an API query; repeatable, AND-ed. No `~` or `in`. |
54
- | `--follow` | | Keep the menu's rows current over native-api `listen`: snapshot frames, a `synced` line, then a line per change, then a summary (NDJSON for json/yaml/ndjson). Single target, list menus whose print takes follow-only. |
55
- | `--sample` | `<interval>` | Read the menu again every interval (with a unit, e.g. `5s`): one line per complete read, then a summary. With --wait, sets polling cadence and returns one final envelope. Sees counters and state listen never reports. Single target; rest-api or native-api. |
54
+ | `--follow` | | Keep the menu's rows current over native-api `listen`: a `started` line, snapshot frames, a `synced` line, then a line per change, then a summary (NDJSON for json/yaml/ndjson). Single target, list menus whose print takes follow-only. |
55
+ | `--sample` | `<interval>` | Read the menu again every interval (with a unit, e.g. `5s`): a `started` line, one line per complete read, then a summary. With --wait, sets polling cadence and returns one final envelope. Sees counters and state listen never reports. Single target; rest-api or native-api. |
56
56
  | `--wait` | `<deadline>` | Wait for a successful read or a condition until this overall deadline, with a unit (e.g. `2m`). Retries refused, closed, reset and timed-out connections and a down or unreachable host; other errors fail at once. One final envelope; single target. |
57
57
  | `--until` | `<expr>` | With --wait: succeed when any selected row matches this --query grammar predicate; repeatable, AND-ed. Returns the matching rows. Already true succeeds on the initial read. |
58
58
  | `--until-empty` | | With --wait: succeed on a completed read with zero rows selected by --query. Silence and failed reads never satisfy it. |
@@ -66,7 +66,7 @@ Usage: centrs retrieve <target> <routeros-path> [flags] | centrs retrieve <targe
66
66
  | `--cdb-password` | `<password>` | Decrypt an encrypted WinBox CDB file. |
67
67
  | `--resolve` | &lt;none\|arp&gt; | Resolve a MAC-address target to an IP via the host ARP cache (default none). |
68
68
  | `--validate / --no-validate` | | Enable or disable inspect-backed preflight validation. |
69
- | `--verbose` | | Show resolved setting sources in text output. |
69
+ | `--verbose` | | Show resolved setting sources in text output, on stderr. |
70
70
 
71
71
  ## execute
72
72
 
@@ -87,6 +87,8 @@ Usage: centrs execute <target> <command> [flags] | centrs execute <target...> --
87
87
  | `--default` | | Select the reserved `__default__` record. |
88
88
  | `--quickchr` | `<name>` | Target a running quickchr-managed CHR VM by name (repeatable; fans out when repeated). Exclusive of positional targets and CDB selectors; conflicts with direct connection overrides (--host/--port/--username/--password, and --ssh-key where the command has it). |
89
89
  | `--concurrency` | `<n>` | Max in-flight targets during fan-out (transport-aware default: rest-api 8, native-api 4). |
90
+ | `--cmr` | | Run on CMR clients through their controller: the `<target>` is the controller (RouterOS 7.26beta1+, `cmr` package). Needs `--labels`; native-api or rest-api only. |
91
+ | `--labels` | `<expr>` | With `--cmr`: RouterOS label expression selecting clients (comma list; plain = OR, `+` AND, `-` AND NOT, `all`). |
90
92
  | `--host` | &lt;host\|url&gt; | Host or base URL to dial. With no `<target>` it is the target (no CDB lookup); with one it overrides the resolved host. |
91
93
  | `--port` | `<port>` | Override the resolved management port. |
92
94
  | `--username / --user / -u` | `<name>` | RouterOS username (aliases `--user`, `-u`). Falls back to `CENTRS_USERNAME`. |
@@ -102,8 +104,8 @@ Usage: centrs execute <target> <command> [flags] | centrs execute <target...> --
102
104
  | `--max-results` | `<bytes>` | Fail if the rendered envelope exceeds this byte budget. |
103
105
  | `--format` | &lt;text\|json\|yaml&gt; | Output format for the CLI response. Defaults to text; use --json or --format json for the structured envelope. |
104
106
  | `--json` | | Shortcut for `--format json`. |
105
- | `--verbose` | | Include additional context in text output. |
106
- | `--` | | End centrs option parsing: every following token is the literal RouterOS command, even flag-shaped ones (e.g. `-- /interface print where disabled=yes`). |
107
+ | `--verbose` | | Include additional context in text output, on stderr. |
108
+ | `--` | | End centrs option parsing: every following token is the literal RouterOS command (e.g. `-- /interface print where disabled=yes`). Put centrs flags before it; one of execute's own long flags after it is refused as `usage/flag-after-separator`. |
107
109
 
108
110
  ## api
109
111
 
@@ -124,7 +126,7 @@ Usage: centrs api <router> <endpoint> [flags] | centrs api --host <host|url> <en
124
126
  | `--attribute / --proplist` | `<a,b>` | Property projection → `.proplist`. |
125
127
  | `--raw` | | Strip the envelope; emit bare RouterOS JSON. Defaults `--validate` to false (an explicit `--validate=true` still runs the gate); does not imply `--yes`. |
126
128
  | `--yes` | | Confirm a mutating (non-read) request in non-interactive runs. |
127
- | `--stream` | | Stream the command's native replies (`!re` rows and `!empty`) as NDJSON, then a summary envelope. Never changes the command: a GET streams one print. To follow changes, request `<menu>/listen`, which implies it. |
129
+ | `--stream` | | Stream the command's native replies (`!re` rows and `!empty`) as NDJSON: a `started` line, a line per reply, then a summary envelope. Never changes the command: a GET streams one print. To follow changes, request `<menu>/listen`, which implies it. |
128
130
  | `--count` | `<n>` | Stop a `--stream` after N `!re` rows; `!empty` replies do not count (not the device count= argument). |
129
131
  | `--duration` | `<dur>` | Stop a `--stream` after this wall-clock window (e.g. `5s`). |
130
132
  | `--via` | &lt;rest-api\|native-api&gt; | Pin the transport; no silent downgrade. Default rest-api. |
@@ -148,7 +150,7 @@ Usage: centrs api <router> <endpoint> [flags] | centrs api --host <host|url> <en
148
150
  | `--validate / --no-validate` | | Run `/console/inspect` validation before the request (default true; `--validate=false` also accepted). |
149
151
  | `--format` | &lt;json\|ndjson\|yaml\|text&gt; | Output format. Defaults to json for api; `CENTRS_FORMAT` overrides. |
150
152
  | `--json` | | Shortcut for `--format json`. |
151
- | `--verbose` | | Include additional context in text output. |
153
+ | `--verbose` | | Include additional context in text output, on stderr. |
152
154
 
153
155
  ## transfer
154
156
 
@@ -188,7 +190,7 @@ Usage: centrs transfer <router>|--host <host> upload <local> [remote] | download
188
190
  | `--cdb-file` | `<path>` | Read target credentials from a WinBox CDB file. |
189
191
  | `--cdb-password` | `<password>` | Decrypt an encrypted WinBox CDB file. |
190
192
  | `--validate / --no-validate` | | Enable or disable the existence-probe preflight. |
191
- | `--verbose` | | Show the resolved transport in text output. |
193
+ | `--verbose` | | Show the resolved transport in text output, on stderr. |
192
194
 
193
195
  ## terminal
194
196
 
@@ -177,8 +177,29 @@ Notes:
177
177
  lossless rule applies to **stdout** only: **stderr** carries progress and
178
178
  interactive prompts (discover scan progress, write/ambiguity confirmation) and
179
179
  is exempt — use it sparingly, since most operations are fast.
180
+ - **Text stdout is data** (#470). In text mode a single-target `execute`,
181
+ `api`, `retrieve` or `transfer` writes only `data` (or its error) to stdout.
182
+ The notes around it go to stderr: the `--verbose` header first, then one
183
+ `warning [<code>]: <message>` line per warning, then the `Tips:` footer, on
184
+ success and on failure alike. `transport/provider-trust` is left out of text
185
+ because it rides every quickchr run over TLS or SSH. `--json` keeps
186
+ everything, `api --raw` stays a bare payload, and fan-out text is a report,
187
+ not data, so it keeps its warnings on stdout. A text stream writes only its
188
+ data rows (`frame`, `sample`) to stdout; its `started`, `synced` and
189
+ `summary` lines, tips, warnings and errors go to stderr (#473).
180
190
  - `data` is the only field whose *shape* varies by command. Everything else —
181
191
  including `warnings` and `tips` — is invariant in shape; only contents differ.
192
+ - **Shared meta is written once** (#461, #462). A fan-out target's
193
+ `meta.settings` keeps only the keys whose source differs from the outer
194
+ `meta.settings`, and its `meta.operation` drops `request` and `auth` when
195
+ they equal the outer operation's. When every target agrees on `auth`, or
196
+ on a setting the outer `meta.settings` lacks, it moves to the outer
197
+ envelope. A reader takes the target's value, else
198
+ the outer one (`expandFanoutTarget`). A stream (`retrieve --follow`,
199
+ `retrieve --sample`, `api --stream`) opens with a `started` line that
200
+ carries the full meta and any request advice in `tips`, and ends with a
201
+ full-meta `summary` (`--follow`'s `synced` is full too). The data lines
202
+ between (`frame`, `sample`) carry only `meta.operation` (#473).
182
203
 
183
204
  ## Error model
184
205
 
@@ -304,12 +325,16 @@ Fan-out output is the locked **`FanoutData`** envelope: `data = { summary, targe
304
325
  is an inner `ok:false` envelope (output, not metadata). `targets[]` is reassembled
305
326
  in resolved record order, not completion order, so repeated runs diff cleanly. The
306
327
  **process exit code** is granular and uniform across fan-out commands: `0` every
307
- target ok (or an empty selection), `2` partial (some ok, some failed), `1`
308
- orchestration error or every target failed.
328
+ target ok (or an empty selection for a read), `2` partial (some ok, some
329
+ failed), `1` orchestration error or every target failed. An empty selection is
330
+ a warning for a read and an orchestration error for a write, `--yes` or not,
331
+ so a mistyped selector never passes for a successful bulk write (#460).
309
332
 
310
333
  A multi-target **RouterOS write** (e.g. `execute` / `api` add/set/remove across a
311
334
  selection) is gated by **`--yes`**, confirmed **once** up front; when it is missing
312
- the error names the blast radius (how many routers). The separate **`--force`**
335
+ the error names the blast radius (how many routers). A write that was **sent** is
336
+ never retried, because it may already have run; it fails with the warning
337
+ `transport/write-outcome-unknown` instead (#448). The separate **`--force`**
313
338
  gate is scoped to destructive **`devices` CDB mutations** (e.g. `devices remove`
314
339
  across a group), not RouterOS writes. `terminal`, `api --stream`,
315
340
  `retrieve --follow` and `retrieve --sample` are single-session, not fan-out
@@ -365,6 +390,32 @@ single host/port: that shape is the contract quickchr's descriptor and TikTOML's
365
390
  exists to prevent. A `--via` whose service key a provider does not supply is a
366
391
  typed unsupported-provider error, never a silent fallback.
367
392
 
393
+ #### Controller-mediated selection (CMR)
394
+
395
+ A **CMR controller** (RouterOS 7.26beta1+) is a third kind of source: a live
396
+ inventory whose members centrs never dials. `execute <controller> --cmr
397
+ --labels <expr>` (#450) resolves the **controller** as an ordinary single
398
+ target, and only its credentials are used. RouterOS's label expression selects
399
+ the members, and the command runs on each through the controller's
400
+ `run-script`. Normative:
401
+
402
+ - CMR is a selection source, **not a protocol**. `--via` pins the controller
403
+ transport, and a CMR failure never falls back to dialing a client.
404
+ - A member is bound by the controller's row id, never by its display name. A
405
+ result that cannot be bound to the member it was validated on is not run, or
406
+ is reported as an unknown outcome.
407
+ - Validation runs **on each client** (`:parse` through CMR) and must be complete
408
+ before it counts. A client that fails validation is never sent the command,
409
+ and an incomplete selection runs nothing.
410
+ - A CMR dispatch is **never retried**. A timeout, cancellation or lost call
411
+ after sending is `cmr/outcome-unknown`, because the client script may still
412
+ be running.
413
+ - CMR mode is not on MCP until delegated authorization is designed (#449).
414
+ Registering a controller must not authorize its clients.
415
+
416
+ The contract and its device evidence are in
417
+ [`commands/execute/README.md` → CMR](../commands/execute/README.md#cmr-clients-through-their-controller---cmr).
418
+
368
419
  ### MCP surface: the CDB is the allowlist
369
420
 
370
421
  The MCP frontend (`commands/mcp/`) is an adapter over this core, not a new set of
package/docs/MATRIX.md CHANGED
@@ -56,6 +56,11 @@ and implementation sequencing in the linked issues.
56
56
  - `retrieve`, `api`, `execute`, and `transfer` share the target-selection and
57
57
  fan-out contracts from `docs/CONSTITUTION.md`; command-specific boundaries and
58
58
  evidence are in their command READMEs and integration tests.
59
+ - `execute --cmr` (#450) is not a grid column: CMR is a selection source, and
60
+ its controller transports are the `execute` rest-api/native-api cells.
61
+ CMR1–CMR7 and CMR10–CMR14 pass on a 7.26beta1 CHR lab over both, and
62
+ CMR8–CMR9 over native-api (`test/integration/execute-cmr.test.ts`). CMR0 (a
63
+ router without CMR) runs on the channel's CHR in `execute.test.ts`.
59
64
  - There is no `update` command. RouterOS CLI-shaped writes ride `execute`; the
60
65
  structured operation surface is `api`.
61
66
  - `api --stream` absorbs the former `stream`/`tail` surface; native-api stream
@@ -177,15 +182,27 @@ Wire, auth, TCP/UDP, and CI caveats live in `commands/btest/README.md` and the
177
182
 
178
183
  ## Current priority
179
184
 
180
- **First: the native-api / NDJSON line** (maintainer decision on
185
+ **First: the CMR track** (maintainer decision 2026-10-12 on
186
+ [#441](https://github.com/tikoci/centrs/issues/441)). Done: `execute --cmr`
187
+ (#450), which runs one validated command on CMR clients through their
188
+ controller. The contract is in
189
+ [`commands/execute/README.md` → CMR](../commands/execute/README.md#cmr-clients-through-their-controller---cmr),
190
+ with examples CMR0–CMR15. Also done: the fleet blast-radius gate for
191
+ controller fleet commands run with plain `execute` or `api` (#453), with
192
+ examples CMR16–CMR18 and api CMR1. The feature map of CMR against centrs is
193
+ on #441 (2026-10-13). Next is the read-only slice of #449 (a `devices` view
194
+ of members and a default controller). Deferred: CDB linkage (#449) and `retrieve --cmr` (#441).
195
+ Live `explain` resumes after this.
196
+
197
+ **The native-api / NDJSON line** (maintainer decision on
181
198
  [#396](https://github.com/tikoci/centrs/issues/396), 2026-10-07): agents in the
182
199
  field poll with `sleep` where a native follow would answer. In order:
183
200
  (1) done: #402, literal `api --stream` with `!empty` frames and
184
201
  `--format ndjson`; (2) done: `retrieve --follow` (#396, unfiltered);
185
202
  (3) done: `retrieve --sample`; (4) #397: one-shot `retrieve --query` done,
186
203
  filtered `--follow` done; #364 (`--wait`/`--until`, same grammar) done;
187
- (5) #398 and #401. Each slice's contract lives in its command
188
- README. Live `explain` (below) resumes after this line.
204
+ (5) #398 and #401 remain open. Each slice's contract lives in its command
205
+ README.
189
206
 
190
207
  **Tightening validation as a whole** is the next track, in the three-step
191
208
  order agreed on [#236](https://github.com/tikoci/centrs/issues/236):
@@ -2,8 +2,24 @@
2
2
 
3
3
  The named CDB group contains no records.
4
4
 
5
+ ## Typical trigger
6
+
7
+ A fan-out selected only by `--group` (no other selector) whose group matched
8
+ no record: a misspelled or case-mismatched group name, or a group whose last
9
+ member was removed.
10
+
11
+ - For a **read**, it is a warning on an `ok: true` fan-out envelope with
12
+ `summary { total: 0, ok: 0, failed: 0 }` and exit code 0.
13
+ - For a **write-shaped** fan-out (`execute` write, a mutating `api` method,
14
+ `transfer upload|remove|mkdir|copy`) it is an outer error (`ok: false`, exit
15
+ code 1), with or without `--yes`. `context.write` is `true`.
16
+
17
+ Any other empty selection (`--where`, `--all`, positionals, mixed) is
18
+ [`cdb/empty-selection`](empty-selection.md).
19
+
5
20
  ## Fix
6
21
 
7
- See [`docs/CONSTITUTION.md`](../../CONSTITUTION.md) for the centrs error
8
- contract. This stub will be expanded with the typical trigger and remediation
9
- for `cdb/empty-group`.
22
+ Run `centrs devices groups` to see the groups that exist and their member
23
+ counts, then re-run with the right name. See
24
+ [`docs/CONSTITUTION.md`](../../CONSTITUTION.md) (Target selection) for the
25
+ selector grammar.
@@ -5,9 +5,15 @@ The target selection matched no CDB records.
5
5
  ## Fix
6
6
 
7
7
  A fan-out selection involving `--where` / `--all` / `--default` / positionals (on
8
- its own or mixed with `--group`) resolved to zero targets. This is reported as a
9
- warning on an `ok: true` fan-out envelope with `summary { total: 0, ok: 0, failed:
10
- 0 }`, not a hard failure — there was simply nothing to run.
8
+ its own or mixed with `--group`) resolved to zero targets.
9
+
10
+ - For a **read**, it is a warning on an `ok: true` fan-out envelope with
11
+ `summary { total: 0, ok: 0, failed: 0 }` and exit code 0: there was simply
12
+ nothing to read.
13
+ - For a **write-shaped** fan-out (`execute` write, a mutating `api` method,
14
+ `transfer upload|remove|mkdir|copy`) it is an outer error (`ok: false`, exit
15
+ code 1), with or without `--yes`, so a mistyped selector does not look like
16
+ a successful bulk write. `context.write` is `true`.
11
17
 
12
18
  A **group-only** empty (bare `--group`, no other selector) is reported as
13
19
  [`cdb/empty-group`](empty-group.md) instead, naming the group that matched nothing;
@@ -0,0 +1,46 @@
1
+ # `cmr/caller-policy-not-enforced`
2
+
3
+ A warning on `execute --cmr`, and on `/cmr/device/run-script` or
4
+ `/cmr/device/reboot` run directly with `execute` or `api`: the controller login
5
+ lacks a policy that the command runs with on the clients, and RouterOS does
6
+ not check it. The login does not limit what a CMR command can change.
7
+
8
+ ## Typical trigger
9
+
10
+ A write through `--cmr` with a controller login whose group lacks `write`,
11
+ `test` or `reboot`, such as a "reader" account given to an agent. CMR runs
12
+ every `run-script` script on each selected client with `read`, `write`,
13
+ `test` and `reboot`, the controller's own row included. On 7.26beta1,
14
+ RouterOS checked only that the caller had `read` and `api`. These results
15
+ come from the CHR reproduction on #456 (REST, two-CHR lab, 2026-10-13); the
16
+ same `run-script` write was seen on real hardware, and the integration test
17
+ re-proves it (CMR15 in `commands/execute/examples.md`):
18
+
19
+ | Controller login | Direct write or reboot | Through CMR |
20
+ | --- | --- | --- |
21
+ | `read,api,rest-api` | `not enough permissions (9)` | `run-script` wrote to a client and rebooted it |
22
+ | `read,test,api,rest-api` | `not enough permissions (9)` | `run-script` wrote to the controller's own row; `/cmr/device/reboot` rebooted a client |
23
+
24
+ MikroTik [says](https://forum.mikrotik.com/t/7-26beta-development-is-released/273582/139) this is fixed for 7.26beta2: a `read` group will not
25
+ be allowed to do it. That has not been checked on a beta2 build yet. centrs
26
+ still warns on any version, because the warning reads the login's policy, not
27
+ the RouterOS version. On a build that refuses the write, the members fail
28
+ instead and the warning only says what the login lacks.
29
+
30
+ `context` names the login (`username`, `group`), the policies it lacks
31
+ (`missing`) and the ones the command runs with (`runsWith`): `read`, `write`,
32
+ `test` and `reboot` for `run-script`, and `reboot` for `/cmr/device/reboot`.
33
+ centrs reads the policy once per run, from the login's own `/user` row and
34
+ `/user/group`, and only for a write. When that read fails (for example a
35
+ RADIUS login with no `/user` row) there is no warning. Other fleet commands
36
+ (`/cmr/device/upgrade`, `pair`, the rule menus) were not measured, so they
37
+ carry no such warning.
38
+
39
+ ## Fix
40
+
41
+ Nothing failed; this is information. Do not treat a controller login as
42
+ read-only once the `cmr` package is installed (on 7.26beta1 at least). The
43
+ gates that hold are centrs' own: pass `--yes` only when you intend the write
44
+ on every selected client, and over MCP keep the controller's record at
45
+ `mcp=ro` (the default), which refuses CMR fleet commands outright. Give an
46
+ agent a login on the controller only if it may change every paired device.
@@ -0,0 +1,15 @@
1
+ # `cmr/cancelled`
2
+
3
+ The operation was cancelled before the command was sent to this CMR client, so
4
+ nothing ran on it.
5
+
6
+ ## Typical trigger
7
+
8
+ The caller aborted (Ctrl-C, or an `AbortSignal` in the library) during
9
+ preflight or before this member's dispatch. Members that had already been sent
10
+ the command are reported as [`cmr/outcome-unknown`](./outcome-unknown.md)
11
+ instead, because cancelling does not stop a client script that has started.
12
+
13
+ ## Fix
14
+
15
+ Re-run when ready.
@@ -0,0 +1,19 @@
1
+ # `cmr/client-disconnected`
2
+
3
+ CMR skipped the client because it is not connected to the controller. The
4
+ command was **not** sent to it.
5
+
6
+ ## Typical trigger
7
+
8
+ The controller answered `run-script` for this client with status `skipped` and
9
+ output `disconnected`. The client is down, unpaired, or cannot reach the
10
+ controller on TCP/54321. A client that has just gone down may instead stall
11
+ until the controller notices; that case surfaces as
12
+ [`cmr/validation-incomplete`](./validation-incomplete.md) or
13
+ [`cmr/outcome-unknown`](./outcome-unknown.md).
14
+
15
+ ## Fix
16
+
17
+ - On the client, `/cmr/client print` should show `status: paired,connected`.
18
+ - Check its route and firewall towards the controller (TCP/54321).
19
+ - Re-run once it reconnects; the other members' results are unaffected.
@@ -0,0 +1,22 @@
1
+ # `cmr/client-script-not-validated`
2
+
3
+ A warning: a caller-addressed `/cmr/device/run-script`, sent through `api` or
4
+ plain `execute`, passed validation **as a controller request**. The `script=`
5
+ it carries was not validated on any CMR client.
6
+
7
+ ## Typical trigger
8
+
9
+ ```sh
10
+ centrs api ctl /cmr/device/run-script -X POST -f labels=all -f 'script=…' --yes
11
+ centrs execute ctl --yes -- '/cmr/device/run-script labels=all script="…"'
12
+ ```
13
+
14
+ `/console/inspect` on the controller checks `labels=` and `script=` as
15
+ arguments. To the controller's `:parse`, the script is an opaque string, so a
16
+ `passed` gate says nothing about whether each client accepts it.
17
+
18
+ ## Fix
19
+
20
+ Use `centrs execute <controller> --cmr --labels <expr> -- '<command>'`. It runs
21
+ `:parse` on every selected client through CMR and sends the command only to the
22
+ clients that accepted it.
@@ -0,0 +1,29 @@
1
+ # `cmr/console-only`
2
+
3
+ The command is one of CMR's interactive console views, so centrs refused it
4
+ before running it.
5
+
6
+ ## Typical trigger
7
+
8
+ `/cmr/device/dashboard` through `execute` or `api`. On RouterOS 7.26beta1:
9
+
10
+ - over the **native API** it closed the API session;
11
+ - over **REST** it never returned, and every other REST request to the
12
+ controller then waited 10–30 s until it timed out.
13
+
14
+ A plain path, abbreviated or not (`/cmr/dev/dash`), is refused before any
15
+ connection opens. Every other spelling is found in the controller's `:parse`
16
+ return during validation, which resolves it to one invocation: console
17
+ spacing, a relative menu change (`/cmr/device; dashboard`), a `:do`/`:if`
18
+ block, a `[...]` substitution, or an `api POST /execute` script. Strings and
19
+ comments that only spell the words are not refused.
20
+
21
+ With `--validate=false` there is no `:parse` return, so only the plain path is
22
+ refused.
23
+
24
+ ## Fix
25
+
26
+ - Open the dashboard in WinBox 4 or WebFig, or run it in
27
+ `centrs terminal <controller>`.
28
+ - For the data behind it, read the menus directly: `centrs retrieve
29
+ <controller> /cmr/device`, `/cmr/alert` or `/cmr/upgrade/job`.
@@ -0,0 +1,33 @@
1
+ # `cmr/empty-selection`
2
+
3
+ The `--labels` expression of `execute --cmr` matched no device in the
4
+ controller's `/cmr/device` inventory, so nothing ran.
5
+
6
+ - For a **read**, it is a warning: `ok: true` with an empty selection and exit
7
+ code 0, the same as an empty CDB group. A collector that polls a sometimes
8
+ empty label stays quiet.
9
+ - For a **write-shaped** command it is an error (`ok: false`, exit code 1),
10
+ with or without `--yes`, so a misspelled label does not look like a
11
+ successful bulk write. RouterOS refuses an empty selection the same way
12
+ (`no devices selected`). `context.write` is `true`.
13
+
14
+ The same code is a **warning** on a CMR fleet command run directly with
15
+ `execute` or `api`, such as `/cmr/vlan/add labels=EDGE` or
16
+ `/cmr/device/reboot labels=Edge`: by the controller's inventory now, its
17
+ selection reaches no device. RouterOS refuses an empty selection for a
18
+ `/cmr/device` command itself; it accepts a rule whose labels match nothing,
19
+ and the rule applies to devices that get the label later.
20
+
21
+ ## Typical trigger
22
+
23
+ - A misspelled label. RouterOS label names are case-sensitive, and an unknown
24
+ label is accepted silently rather than rejected.
25
+ - A missing comma: `labels=office+ap` is one label named `office+ap`; use
26
+ `office,+ap`.
27
+ - Every matching device was removed from the controller.
28
+
29
+ ## Fix
30
+
31
+ Check the labels on the controller (`/cmr/device print` shows `labels` and
32
+ `auto-labels`) and re-run. `--labels all` selects every device, including the
33
+ controller.
@@ -0,0 +1,20 @@
1
+ # `cmr/membership-changed`
2
+
3
+ The CMR client was validated, but before the command was dispatched it changed
4
+ or left the selection. The command was **not** sent to it.
5
+
6
+ ## Typical trigger
7
+
8
+ - The client's labels changed, so `numbers=<id> labels=<expr>` at dispatch
9
+ selected nothing (`failure: no devices selected`).
10
+ - Its `/cmr/device` row disappeared, or its `ids` key changed, between the
11
+ validation pass and dispatch. centrs re-reads the inventory just before
12
+ each member's own call, after any write prompt, so a client replaced
13
+ while the prompt or the fan-out queue waited is caught here. A validation
14
+ result belongs to the key it ran on, so it is not reused for a different one,
15
+ and a replacement row is never adopted.
16
+
17
+ ## Fix
18
+
19
+ Re-run. centrs validates the current selection again and only sends the
20
+ command to clients that passed validation in that run.
@@ -0,0 +1,26 @@
1
+ # `cmr/not-a-controller`
2
+
3
+ `execute --cmr` was pointed at a router that does not answer `/cmr/device`, so
4
+ it is not a CMR controller.
5
+
6
+ centrs then reads the target's version. RouterOS older than 7.26beta1, where
7
+ CMR does not exist, is reported as `routeros/version-unsupported` instead. This
8
+ error means the version has CMR (or could not be read), and `context.version`
9
+ says which.
10
+
11
+ ## Typical trigger
12
+
13
+ - The optional `cmr` package is not installed. Clients only have the built-in
14
+ `/cmr/client`; the controller needs the package.
15
+ - The target is a CMR *client*, not its controller. `context.controllerIdentity`
16
+ and `context.controllerAddress` then name the controller it is paired to.
17
+
18
+ Only a missing menu raises this: native API reports `no such command prefix`,
19
+ REST `no such command or directory (cmr)`. Any other failure reading
20
+ `/cmr/device`, such as a permissions error, is reported unchanged.
21
+
22
+ ## Fix
23
+
24
+ Point the command at the controller: the router with the `cmr` package and
25
+ `/cmr set enabled=yes`. Its `/cmr/device print` lists the clients `--labels`
26
+ selects from.