@tikoci/centrs 0.1.9-next.0 → 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 (65) hide show
  1. package/CHANGELOG.md +177 -40
  2. package/GLOSSARY.txt +8 -0
  3. package/README.md +12 -8
  4. package/SECURITY.md +11 -3
  5. package/commands/api/README.md +39 -15
  6. package/commands/api/examples.md +44 -6
  7. package/commands/execute/README.md +108 -14
  8. package/commands/execute/examples.md +118 -3
  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 +30 -13
  13. package/commands/retrieve/examples.md +10 -6
  14. package/commands/transfer/examples.md +9 -0
  15. package/docs/CLI.md +8 -8
  16. package/docs/CONSTITUTION.md +25 -2
  17. package/docs/MATRIX.md +5 -4
  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/empty-selection.md +17 -3
  22. package/docs/errors/cmr/rule-selects-every-device.md +32 -0
  23. package/docs/errors/cmr/selection-includes-controller.md +27 -0
  24. package/docs/errors/usage/flag-after-separator.md +34 -0
  25. package/package.json +7 -5
  26. package/src/api-fanout.ts +11 -1
  27. package/src/api.ts +205 -65
  28. package/src/btest.ts +7 -2
  29. package/src/cli/api.ts +30 -16
  30. package/src/cli/common.ts +180 -1
  31. package/src/cli/devices.ts +0 -2
  32. package/src/cli/discover.ts +0 -1
  33. package/src/cli/execute.ts +44 -20
  34. package/src/cli/explain.ts +2 -0
  35. package/src/cli/mcp.ts +0 -1
  36. package/src/cli/retrieve.ts +40 -18
  37. package/src/cli/settings.ts +0 -3
  38. package/src/cli/terminal.ts +0 -2
  39. package/src/cli/transfer.ts +10 -8
  40. package/src/cli/version.ts +87 -0
  41. package/src/cli.ts +5 -3
  42. package/src/core/cmr-fleet.ts +486 -0
  43. package/src/core/cmr.ts +78 -0
  44. package/src/core/envelope.ts +32 -3
  45. package/src/core/error-catalog.ts +25 -3
  46. package/src/core/execute-command.ts +144 -19
  47. package/src/core/fanout.ts +130 -6
  48. package/src/errors.ts +2 -1
  49. package/src/execute-cmr.ts +95 -78
  50. package/src/execute-fanout.ts +11 -1
  51. package/src/execute.ts +105 -23
  52. package/src/index.ts +18 -1
  53. package/src/mcp/safety.ts +11 -0
  54. package/src/mcp/tools.ts +4 -1
  55. package/src/resolver/cdb.ts +13 -1
  56. package/src/resolver/index.ts +4 -0
  57. package/src/resolver/selection.ts +37 -1
  58. package/src/resolver/target.ts +96 -8
  59. package/src/retrieve-fanout.ts +4 -1
  60. package/src/retrieve-follow.ts +29 -33
  61. package/src/retrieve-sample.ts +14 -8
  62. package/src/retrieve-stream.ts +38 -6
  63. package/src/retrieve.ts +73 -59
  64. package/src/transfer-fanout.ts +8 -1
  65. package/src/transfer.ts +15 -19
@@ -47,12 +47,23 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
47
47
  write-shaped operation. In script mode every command head in a statement
48
48
  (the statement start and each `[…]` or `{…}` body) must be a read: its verb
49
49
  is the first word past the known menu prefix, so `/ip address print` is a
50
- read and `/user expire-password print` is not (examples W3). Root mutators such as `/import` and indirect
51
- execution through `:execute` are write-shaped too, and so is any command
52
- carrying `file=`: RouterOS redirects command output into a device file, so
53
- `/ip/address/export file=address` creates `address.rsc` and
54
- `/file/print file=test` creates `test.txt` even though `export` and `print`
55
- are read verbs. The shared
50
+ read and `/user expire-password print` is not (examples W3). Script
51
+ directives that only output, branch or compute a value are reads, including
52
+ the pure functions `:serialize`, `:deserialize`, `:convert`, `:delay`,
53
+ `:find`, `:timestamp`, `:rndnum`/`:rndstr` and the `:to*` converters, and the
54
+ `{…}`-bodied `:retry` and `:time` (example W4, #458). The absolute
55
+ `/serialize` spelling counts too; a bare `serialize` keeps the menu-verb rule,
56
+ because after navigation it could name a menu command. RouterOS runs only a
57
+ `{…}` block in a script-typed argument, so every body is visible to the gate.
58
+ Root mutators such as `/import` and indirect
59
+ execution through `:execute` are write-shaped too, as are `:global`, `:set`
60
+ and `:parse`, and so is any command carrying `file=` or `file-name=`
61
+ (including argument prefixes such as `fi=` and `file-n=`, and the spaced
62
+ `file-name value` spelling under print/export/serialize, example W5):
63
+ RouterOS redirects output into a device file, so
64
+ `/ip/address/export file=address` creates `address.rsc`,
65
+ `/file/print file=test` creates `test.txt` and `:serialize … file-name=x`
66
+ creates `x`, even though `export`, `print` and `:serialize` are reads. The shared
56
67
  `rosetta`/`lsp-routeros-ts`
57
68
  canonicalizer is for canonicalization only — never the structured predicate
58
69
  (widening `structured` is a product regression). Pinned by
@@ -180,7 +191,9 @@ duplicate that table. Behavior notes the generated reference cannot carry:
180
191
  - `--` — use it when the command contains tokens that would otherwise be
181
192
  claimed as centrs flags; otherwise the command must be quoted as a single
182
193
  argument. In fan-out, positional targets come BEFORE `--` and the command
183
- AFTER it (see **Target selection**).
194
+ AFTER it (see **Target selection**). centrs flags go before `--` too: one of
195
+ execute's own long flags after it (a trailing `--json`) is refused before
196
+ dialing as `usage/flag-after-separator`, with the corrected line (#459).
184
197
 
185
198
  ### Designed, not implemented
186
199
 
@@ -296,8 +309,12 @@ when the target is itself a CMR client.
296
309
  selects the controller. CDB selectors (`--group`/`--where`/`--all`/
297
310
  `--default`/geo), several targets, and `--via mac-telnet|ssh` are
298
311
  `usage/conflicting-flags`. `--labels` without `--cmr` is refused the same way.
299
- Labels that no device has are accepted silently by RouterOS; an empty
300
- selection is `ok: true` with the warning `cmr/empty-selection`. A device's
312
+ Labels that no device has are accepted silently by RouterOS. An empty
313
+ selection is `ok: true` with the warning `cmr/empty-selection` for a read,
314
+ and the outer error `cmr/empty-selection` for a write-shaped command, with
315
+ or without `--yes` (#460), as RouterOS refuses it (`no devices selected`). A
316
+ mistyped label then exits 1 instead of looking like a successful bulk write.
317
+ A device's
301
318
  identity selects it only while the controller's `/cmr auto-labels` includes
302
319
  `identity` (the default `all` does). On 7.26beta1, after `auto-labels=version`
303
320
  a client's row still *showed* its identity label but no longer matched it.
@@ -336,16 +353,33 @@ when the target is itself a CMR client.
336
353
  - **Write gate:** one `--yes`/TTY confirmation, naming how many clients will run
337
354
  it, how many were excluded, and through which controller. Clients that failed
338
355
  validation are never sent the command.
356
+ - **The controller login is not the limit (#456).** On 7.26beta1, RouterOS
357
+ does not check the caller's policy against a CMR script. `run-script`
358
+ needed only `read` + `api` on the controller login, and
359
+ `/cmr/device/reboot` only `read` + `test` + `api`. A login refused a direct
360
+ write (`not enough permissions (9)`) still wrote to clients and to the
361
+ controller's own row through `run-script`, and rebooted a client through
362
+ both. So a "reader" login on a 7.26beta1 controller is not read-only, and
363
+ centrs' write gate (and `mcp=ro` on MCP) is the only gate. MikroTik
364
+ [says](https://forum.mikrotik.com/t/7-26beta-development-is-released/273582/139) 7.26beta2 refuses this for a `read` group; that is not yet
365
+ checked on a beta2 build. For a write,
366
+ centrs reads the login's own `/user` row and `/user/group` once (both are
367
+ readable with just `read` + `api`). When the login lacks `write`, `test` or
368
+ `reboot`, the result and the confirmation error carry
369
+ `cmr/caller-policy-not-enforced` naming what it lacks. A failed read only
370
+ loses the warning.
339
371
  - **Dispatch:** one call per validated member, sending the input unchanged with
340
372
  `timeout=` set from `--timeout` (capped at 55 s, under CMR's ~60 s per-call
341
373
  ceiling). Every call pins `strategy=parallel fail-policy=continue
342
374
  freeze-frame-interval=20ms`. **Never retried.**
343
375
  - **Outcomes** are a `FanoutData` envelope in inventory order, with exit codes
344
- 0/2/1. Each member's `meta.target.cmr` carries `{controller, id, deviceKey,
345
- address}`. `success` → `data.ret` holds the client's output; a clipped
376
+ 0/2/1. The outer `meta.target` is the controller, and the settings,
377
+ request and login every member shares are written once on the outer
378
+ envelope ([constitution](../../docs/CONSTITUTION.md#result-envelope)). Each
379
+ member's `meta.target.cmr` carries `{id, deviceKey, address}`. `success` → `data.ret` holds the client's output; a clipped
346
380
  output adds `cmr/output-truncated` and `meta.truncated`. `--max-results`
347
381
  applies per member, as in plain execute fan-out: a member whose rendered
348
- envelope exceeds it (UTF-8 bytes) becomes `input/max-results-exceeded`, and
382
+ envelope (with its full meta, as it would render alone) exceeds it (UTF-8 bytes) becomes `input/max-results-exceeded`, and
349
383
  that error says the command already ran (`context.dispatched: true`). A `failed` runtime
350
384
  error maps through the usual `routeros/*` table. `skipped` →
351
385
  `cmr/client-disconnected`.
@@ -366,8 +400,15 @@ when the target is itself a CMR client.
366
400
  - **Validation metadata** reports what ran: `source: ":put [:parse ...] via cmr
367
401
  run-script"`, the same claim the console gate makes. There is no client
368
402
  `/console/inspect` stage.
369
- - **Not on MCP** until delegated authorization is designed (#449). `retrieve
370
- --cmr` is deferred (#441).
403
+ - **No progress stream.** `run-script` returns a client's output only when its
404
+ script ends, and no `/cmr` menu records a running script. To watch a long
405
+ CMR job, follow what it changes on a client:
406
+ `centrs retrieve <client> /ip/address --follow` while
407
+ `execute <controller> --cmr` adds addresses. That only works when the client
408
+ can also be reached directly; otherwise re-run a read through `--cmr`.
409
+ - **Not on MCP** until delegated authorization is designed (#449). That design
410
+ must not assume RouterOS refuses a fleet write for a `!write` login (see
411
+ above). `retrieve --cmr` is deferred (#441).
371
412
 
372
413
  `/cmr/device/dashboard` is an interactive console view, refused as
373
414
  `cmr/console-only`. A plain path, abbreviated or not (`/cmr/dev/dash`), is
@@ -382,6 +423,59 @@ Calling `/cmr/device/run-script` yourself, through `api` or plain `execute`,
382
423
  validates only the controller request. The envelope then carries the warning
383
424
  `cmr/client-script-not-validated`.
384
425
 
426
+ ### Fleet commands run directly (#453)
427
+
428
+ A controller's fleet commands are one request to one router that act on many
429
+ devices: `/cmr/device/{reboot,upgrade,pair,run-script}`,
430
+ `/cmr/upgrade/trigger`, `/cmr/upgrade/job/run-next`, and `add`, `set`,
431
+ `remove`, `enable` or `disable` on `/cmr/vlan`, `/cmr/wifi`,
432
+ `/cmr/wifi/radio` and `/cmr/upgrade`. They are write-shaped, so they need
433
+ `--yes`. Before that gate, plain `execute` (and `api`) reads the controller's
434
+ `/cmr/device` inventory, read-only, and resolves which rows the command
435
+ reaches, where it can (see "Not resolved" below). The command still runs
436
+ exactly as typed.
437
+
438
+ - **The gate names the reach.** The prompt and the `usage/confirmation-required`
439
+ summary say how many CMR devices the command reaches and whether the
440
+ controller's own row (`controller=true`) is one of them. `error.context.cmr`
441
+ has `devices`, `connected`, `controllerIncluded`, `identities` and
442
+ `inventory`.
443
+ - **How the selection is resolved.** `numbers` matches a row's `.id` or
444
+ identity. `labels` follows the manual's rules over the row's `labels` and
445
+ `auto-labels`: plain labels are OR, `+label` must match, `-label` excludes,
446
+ `all` selects every row, and an expression with only exclusions starts from
447
+ every row. On a 7.26beta1 lab this matched the device's own selection
448
+ (a `run-script` probe per row) for every expression tried; the lab test
449
+ re-checks it (CMR-D9). It is the controller's current view: a disconnected
450
+ device's `auto-labels` can be stale. `/cmr/upgrade/trigger` reaches the rows
451
+ whose `upgrade-rule` it names. A rule's `set`, `remove`, `enable` or
452
+ `disable` reads the rule's own `labels`; a `set` that changes `labels`
453
+ reaches the old and the new devices. WiFi band labels (`+2ghz`, `+5ghz`,
454
+ `+6ghz`) pick radios, not devices, so they are left out.
455
+ - **Warnings, with or without `--yes`:**
456
+ - `cmr/selection-includes-controller`: the selection includes the
457
+ controller's own row (`labels=all` does, per the manual).
458
+ - `cmr/rule-selects-every-device`: a rule command with no device `labels`
459
+ (for `/cmr/vlan`, no `port-labels` either). The manual warns that such a
460
+ VLAN rule provisions every port of every connected device, the controller
461
+ included, and cuts the fleet's management access.
462
+ - `cmr/empty-selection`: by the controller's inventory now, nothing is
463
+ selected. RouterOS refuses this for a `/cmr/device` command; it accepts
464
+ a rule whose labels match nothing.
465
+ - `cmr/caller-policy-not-enforced` for `run-script` and `reboot`, as for
466
+ `--cmr` above (#456).
467
+ - **Not resolved:** an abbreviated selector argument (`lab=all`),
468
+ `--via ssh` or `mac-telnet`, `/cmr/upgrade/job/run-next` (a job's devices
469
+ are not in the inventory), and a failed read. The gate still names the
470
+ fleet command and says why its reach is unknown, for example `would reach
471
+ the CMR devices it selects through controller ctl (not resolved: an upgrade
472
+ job's devices are not in the inventory)`, and `error.context.cmr` has
473
+ `unresolved` instead of the counts. A failed read never blocks the command,
474
+ and after a transport or login failure the policy read is skipped. Two
475
+ cases are not recognized as fleet commands at all and keep the plain
476
+ wording: script-shaped input (a positional id or `[find …]`) and CDB fan-out
477
+ to several controllers, which has its own gate.
478
+
385
479
  ## mac-telnet L2 validation
386
480
 
387
481
  Decided (2026-06-06): the real-L2 integration path is `@tikoci/quickchr`'s
@@ -573,6 +573,47 @@ argument happens to be `print`, so it returns `usage/confirmation-required`.
573
573
  Every command head in a statement is checked, including `[…]` and `do={…}`
574
574
  bodies.
575
575
 
576
+ ### W4. Pure script functions are reads; an output file is not (#458)
577
+
578
+ ```bash
579
+ centrs execute $R ':put [:serialize to=json [/system/resource/print as-value]]' --via rest-api --username $U --password $P </dev/null
580
+ ```
581
+
582
+ Envelope: `ok: true` without `--yes`; `data.ret` parses as JSON whose
583
+ `version` is the router's. `:delay`, `:convert`, `:deserialize`, `:find`,
584
+ `:timestamp`, `:rndnum`, `:rndstr`, the `:to*` converters, `:retry` and `:time` are reads
585
+ too; any command inside their arguments or `{…}` bodies is still checked.
586
+ RouterOS runs only a `{…}` block in a script-typed argument and refuses a quoted
587
+ string there, so no body hides in text. `:serialize … file-name=centrs-w4`
588
+ creates a device file, so it returns `usage/confirmation-required`, as `file=`
589
+ does under a read verb. `:global`, `:set`, `:execute` and `:parse` stay writes.
590
+
591
+ ### W5. Abbreviated output-file arguments keep the write gate
592
+
593
+ ```bash
594
+ centrs execute $R '/system identity export fi=centrs-w5' --via rest-api --username $U --password $P </dev/null
595
+ centrs execute $R ':serialize to=json value=1 file-n=centrs-w5.json' --via rest-api --username $U --password $P </dev/null
596
+ ```
597
+
598
+ Both return `usage/confirmation-required` and create no file. The same
599
+ commands with `--yes` pass validation and create `centrs-w5.rsc` and
600
+ `centrs-w5.json`, respectively. Native API has the same result. Through
601
+ `centrs_execute`, an `mcp=ro` record returns `cdb/write-not-permitted` with or
602
+ without `confirm: true`, and creates no file.
603
+
604
+ RouterOS also accepts `fi "centrs-w5"` and `file-n "centrs-w5.json"`, without
605
+ `=`. They keep the same confirmation and MCP policy gates, including when
606
+ serialize's file argument follows a nested value expression.
607
+
608
+ `test/integration/execute-write-gate.test.ts` proves the refusal, absence and
609
+ confirmed creation. The export control runs on the selected channel; the
610
+ serialize control pins CHR 7.26beta1, where its file output was reproduced.
611
+ The export uses CLI script spelling: structured attribute validation requires
612
+ full argument names and rejects `fi` as `validation/unknown-attribute`.
613
+ This is a grounding version, not an introduction boundary. Unknown or
614
+ ambiguous output-file prefixes stay write-shaped until the device resolves
615
+ them.
616
+
576
617
  ### R1. A post-validation `/execute` rejection fails the overall envelope
577
618
 
578
619
  ```bash
@@ -622,6 +663,16 @@ centrs execute --group no-such-group --via rest-api --cdb-file $CDB --json -- /s
622
663
  `ok: true`, `data.summary = { total: 0, ok: 0, failed: 0 }`,
623
664
  `warnings` include `cdb/empty-group`. Exit `0`.
624
665
 
666
+ The same group with a write is an outer error, with or without `--yes`, so a
667
+ misspelled group cannot pass for a successful bulk write (#460):
668
+
669
+ ```bash
670
+ centrs execute --group no-such-group --via rest-api --cdb-file $CDB --yes --json -- '/system/identity/set name=x'
671
+ ```
672
+
673
+ `ok: false`, `error.code` `cdb/empty-group`, `error.context.write` `true`.
674
+ Exit `1`. Nothing is dialed.
675
+
625
676
  ### F3. `--where` device-class selector (subset)
626
677
 
627
678
  ```bash
@@ -763,7 +814,7 @@ transport.
763
814
  ### Q3. Repeated `--quickchr` fans out; an unknown machine is an inner failure
764
815
 
765
816
  ```bash
766
- centrs execute --quickchr $NAME --quickchr no-such-machine -- ':put 1' --json
817
+ centrs execute --quickchr $NAME --quickchr no-such-machine --json -- ':put 1'
767
818
  ```
768
819
 
769
820
  Envelope: outer `ok: true`, `data.summary = { total: 2, ok: 1, failed: 1 }`;
@@ -775,9 +826,9 @@ the unknown machine's inner error is `quickchr/machine-not-found`. Exit code 2
775
826
  `test/integration/execute-cmr.test.ts` runs these on a two-CHR CMR lab
776
827
  (`test/integration/cmr-lab.ts`): `$CTL` is a 7.26beta1 controller with the
777
828
  `cmr` package, paired with one client over a socket link. Examples CMR1–CMR7
778
- and CMR10–CMR14 run over **both** `--via native-api` and `--via rest-api` to
829
+ and CMR10–CMR18 run over **both** `--via native-api` and `--via rest-api` to
779
830
  the controller, with validation on. The same test first asserts the device
780
- behaviour the design depends on (CMR-D1–D8 in the test file).
831
+ behaviour the design depends on (CMR-D1–D9 in the test file).
781
832
 
782
833
  ### CMR0. A router without CMR
783
834
 
@@ -856,6 +907,16 @@ centrs execute --quickchr $CTL --cmr --labels no-such-label -- ':put x'
856
907
  `ok: true`, `data.summary.total` is 0, warning `cmr/empty-selection`, exit
857
908
  code 0.
858
909
 
910
+ The same labels with a write are an outer error, with or without `--yes`, as
911
+ RouterOS itself refuses an empty selection (#460):
912
+
913
+ ```bash
914
+ centrs execute --quickchr $CTL --cmr --labels no-such-label --yes -- '/system/note/set note="x"'
915
+ ```
916
+
917
+ `ok: false`, `error.code` `cmr/empty-selection`, `error.context.write` `true`,
918
+ exit code 1. No note changes.
919
+
859
920
  ### CMR8. CLI exit code for a mixed result
860
921
 
861
922
  CMR2 through `runCli` exits `2`.
@@ -920,6 +981,21 @@ centrs execute --quickchr <client> --cmr --labels all -- ':put x'
920
981
  `cmr/not-a-controller`: the client runs 7.26beta1 but has no `cmr` package.
921
982
  `context.controllerIdentity` names the controller it is paired to.
922
983
 
984
+ ### CMR15. A controller login without `write` or `reboot`
985
+
986
+ The controller has a group with `read,test,api,rest-api,!write,!reboot,!sensitive,!policy`
987
+ and a user `$READER` in it. That login cannot write on the controller
988
+ directly, but:
989
+
990
+ ```bash
991
+ centrs execute 127.0.0.1 --port $API --username $READER --password $PW --cmr --labels <client> --yes -- '/system/note/set note="<marker>"'
992
+ ```
993
+
994
+ The target is `ok`, the client's note reads `<marker>`, and the envelope warns
995
+ `cmr/caller-policy-not-enforced` with `context.missing` `["write", "reboot"]`:
996
+ CMR ran the script with `read,write,test,reboot` (#456). The same write as the
997
+ lab's admin login has no such warning.
998
+
923
999
  ### CMR13. The dashboard behind a relative menu change
924
1000
 
925
1001
  ```bash
@@ -931,3 +1007,42 @@ The first is `cmr/console-only`: the controller's `:parse` resolves it to
931
1007
  `/cmr/device/;(evl /cmr/device/dashboard)`, so it is refused before it runs.
932
1008
  The second is `ok` with `data.ret` `CMR device dashboard`: a string that spells
933
1009
  the words is not a command.
1010
+
1011
+ ### CMR16. A fleet command names the devices it reaches (#453)
1012
+
1013
+ ```bash
1014
+ centrs execute --quickchr $CTL -- '/cmr/device/reboot labels=all'
1015
+ ```
1016
+
1017
+ Without `--yes` (no TTY) this is `usage/confirmation-required`. Its summary
1018
+ says it would reach 2 CMR devices, the controller itself among them, and
1019
+ `error.context.cmr` is `{devices: 2, controllerIncluded: true}` with both
1020
+ identities. The envelope warns `cmr/selection-includes-controller`.
1021
+ Nothing reboots.
1022
+
1023
+ ### CMR17. A rule with no labels
1024
+
1025
+ ```bash
1026
+ centrs execute --quickchr $CTL --yes -- '/cmr/vlan/add vlan-ids=4001 disabled=yes comment=<marker>'
1027
+ ```
1028
+
1029
+ `ok: true` with the warning `cmr/rule-selects-every-device`: the rule has no
1030
+ `labels` and no `port-labels`. It is added disabled, so nothing is
1031
+ provisioned. Removing it by its `.id` warns the same way, read from the rule:
1032
+
1033
+ ```bash
1034
+ centrs execute --quickchr $CTL --yes -- '/cmr/vlan/remove numbers=<id>'
1035
+ ```
1036
+
1037
+ ### CMR18. `run-script` called directly by a login without `write` or `reboot`
1038
+
1039
+ With CMR15's restricted login:
1040
+
1041
+ ```bash
1042
+ centrs execute 127.0.0.1 --port $API --username $READER --password $PW --yes -- '/cmr/device/run-script labels=<client> script=":put centrs-cmr18"'
1043
+ ```
1044
+
1045
+ `ok: true`, with the warnings `cmr/caller-policy-not-enforced`
1046
+ (`context.missing` `["write", "reboot"]`) and
1047
+ `cmr/client-script-not-validated`. The same command as the lab's admin login
1048
+ has neither the policy warning nor `cmr/selection-includes-controller`.
@@ -392,7 +392,8 @@ bare menu path. The positional grammar stays **target-first like every other
392
392
  router-taking command** (amended in PR review — the shared resolver/selection
393
393
  helpers assume it): one positional means offline and it *is* the input; two
394
394
  positionals mean live with the router first; `--` is accepted before the
395
- input; **`--file` replaces the input positional in either form**, so with it
395
+ input, and one of explain's own long flags after it is refused as
396
+ `usage/flag-after-separator` (#459); **`--file` replaces the input positional in either form**, so with it
396
397
  every positional is a target. Adding a
397
398
  router therefore never reinterprets a previously valid offline invocation.
398
399
 
@@ -178,6 +178,17 @@ MCP server applies it as a hard allowlist:
178
178
  regardless of `confirm`; a local CDB mutation through `centrs_devices` or
179
179
  `centrs_discover` `save` fails with `usage/confirmation-required` unless
180
180
  explicitly confirmed.
181
+ - **`mcp=ro` does not rest on RouterOS policy.** On a 7.26beta1 CMR
182
+ controller, a login without `write` or `reboot` wrote to a client and to the
183
+ controller's own row through `/cmr/device/run-script`, and rebooted a client
184
+ through `run-script` and `/cmr/device/reboot`. That is the CHR reproduction
185
+ on #456; the integration test re-proves the `run-script` write (CMR15).
186
+ MikroTik [says](https://forum.mikrotik.com/t/7-26beta-development-is-released/273582/139) 7.26beta2 will refuse this for a `read` group, but
187
+ centrs' classification does not depend on it: every CMR fleet command
188
+ (`run-script`, `reboot`, `upgrade`, `pair`, `/cmr/upgrade/trigger`, …) is
189
+ write-shaped over every path, pinned by `test/unit/write-gate-tables.test.ts`,
190
+ and `mcp=ro` refuses them whatever the controller login's group is. Any
191
+ future MCP route to CMR clients (#449) must keep that assumption.
181
192
  - **`--allow-adhoc-targets`** (off by default) is reserved for one-off lab/power
182
193
  user flows. Current RouterOS-facing tools expose no inline host+credential
183
194
  schema, so the CDB allowlist remains the only active target source.
@@ -131,6 +131,12 @@ With `$NAME` registered `mcp=ro` (default):
131
131
  Envelope: `ok: false`, `error.code = "cdb/write-not-permitted"`. The router is not
132
132
  mutated even though `confirm` is true — the CDB policy is authoritative.
133
133
 
134
+ Output-file arguments count as writes even when abbreviated:
135
+ `:serialize to=json value=1 file-n=out.json` and
136
+ `/system identity export fi=out` are refused with or without `confirm`.
137
+ The CHR refusal and file read-back controls are
138
+ [execute example W5](../execute/examples.md#w5-abbreviated-output-file-arguments-keep-the-write-gate).
139
+
134
140
  ### 8. Write against an `mcp=rw` target succeeds with `confirm: true`
135
141
 
136
142
  With `$NAME` registered `mcp=rw`:
@@ -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
 
@@ -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
 
@@ -104,8 +104,8 @@ Usage: centrs execute <target> <command> [flags] | centrs execute <target...> --
104
104
  | `--max-results` | `<bytes>` | Fail if the rendered envelope exceeds this byte budget. |
105
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. |
106
106
  | `--json` | | Shortcut for `--format json`. |
107
- | `--verbose` | | Include additional context in text output. |
108
- | `--` | | 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`. |
109
109
 
110
110
  ## api
111
111
 
@@ -126,7 +126,7 @@ Usage: centrs api <router> <endpoint> [flags] | centrs api --host <host|url> <en
126
126
  | `--attribute / --proplist` | `<a,b>` | Property projection → `.proplist`. |
127
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`. |
128
128
  | `--yes` | | Confirm a mutating (non-read) request in non-interactive runs. |
129
- | `--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. |
130
130
  | `--count` | `<n>` | Stop a `--stream` after N `!re` rows; `!empty` replies do not count (not the device count= argument). |
131
131
  | `--duration` | `<dur>` | Stop a `--stream` after this wall-clock window (e.g. `5s`). |
132
132
  | `--via` | &lt;rest-api\|native-api&gt; | Pin the transport; no silent downgrade. Default rest-api. |
@@ -150,7 +150,7 @@ Usage: centrs api <router> <endpoint> [flags] | centrs api --host <host|url> <en
150
150
  | `--validate / --no-validate` | | Run `/console/inspect` validation before the request (default true; `--validate=false` also accepted). |
151
151
  | `--format` | &lt;json\|ndjson\|yaml\|text&gt; | Output format. Defaults to json for api; `CENTRS_FORMAT` overrides. |
152
152
  | `--json` | | Shortcut for `--format json`. |
153
- | `--verbose` | | Include additional context in text output. |
153
+ | `--verbose` | | Include additional context in text output, on stderr. |
154
154
 
155
155
  ## transfer
156
156
 
@@ -190,7 +190,7 @@ Usage: centrs transfer <router>|--host <host> upload <local> [remote] | download
190
190
  | `--cdb-file` | `<path>` | Read target credentials from a WinBox CDB file. |
191
191
  | `--cdb-password` | `<password>` | Decrypt an encrypted WinBox CDB file. |
192
192
  | `--validate / --no-validate` | | Enable or disable the existence-probe preflight. |
193
- | `--verbose` | | Show the resolved transport in text output. |
193
+ | `--verbose` | | Show the resolved transport in text output, on stderr. |
194
194
 
195
195
  ## terminal
196
196