@tikoci/centrs 0.1.9-next.0 → 0.1.9-next.23

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 (88) hide show
  1. package/CHANGELOG.md +177 -40
  2. package/GLOSSARY.txt +32 -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 +137 -3
  9. package/commands/explain/README.md +430 -129
  10. package/commands/explain/examples.md +260 -1
  11. package/commands/mcp/README.md +11 -0
  12. package/commands/mcp/examples.md +6 -0
  13. package/commands/retrieve/README.md +30 -13
  14. package/commands/retrieve/examples.md +10 -6
  15. package/commands/transfer/examples.md +9 -0
  16. package/docs/CLI.md +8 -8
  17. package/docs/CONSTITUTION.md +29 -2
  18. package/docs/MATRIX.md +26 -13
  19. package/docs/errors/cdb/empty-group.md +19 -3
  20. package/docs/errors/cdb/empty-selection.md +9 -3
  21. package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
  22. package/docs/errors/cmr/empty-selection.md +17 -3
  23. package/docs/errors/cmr/rule-selects-every-device.md +32 -0
  24. package/docs/errors/cmr/selection-includes-controller.md +27 -0
  25. package/docs/errors/usage/flag-after-separator.md +34 -0
  26. package/package.json +10 -5
  27. package/src/api-fanout.ts +11 -1
  28. package/src/api.ts +205 -65
  29. package/src/btest.ts +7 -2
  30. package/src/cli/api.ts +30 -16
  31. package/src/cli/common.ts +180 -1
  32. package/src/cli/devices.ts +0 -2
  33. package/src/cli/discover.ts +0 -1
  34. package/src/cli/execute.ts +44 -20
  35. package/src/cli/explain.ts +2 -0
  36. package/src/cli/mcp.ts +0 -1
  37. package/src/cli/retrieve.ts +40 -18
  38. package/src/cli/settings.ts +0 -3
  39. package/src/cli/terminal.ts +0 -2
  40. package/src/cli/transfer.ts +10 -8
  41. package/src/cli/version.ts +87 -0
  42. package/src/cli.ts +5 -3
  43. package/src/core/cmr-fleet.ts +486 -0
  44. package/src/core/cmr.ts +78 -0
  45. package/src/core/envelope.ts +32 -3
  46. package/src/core/error-catalog.ts +25 -3
  47. package/src/core/execute-command.ts +144 -19
  48. package/src/core/fanout.ts +130 -6
  49. package/src/errors.ts +2 -1
  50. package/src/execute-cmr.ts +95 -78
  51. package/src/execute-fanout.ts +11 -1
  52. package/src/execute.ts +105 -23
  53. package/src/explain/args.ts +69 -8
  54. package/src/explain/blocks.ts +3 -4
  55. package/src/explain/brace-slots.ts +5 -0
  56. package/src/explain/catalog.ts +13 -6
  57. package/src/explain/defects.ts +1 -0
  58. package/src/explain/group-faults.ts +927 -0
  59. package/src/explain/highlight-projection.ts +10 -8
  60. package/src/explain/operators.ts +20 -19
  61. package/src/explain/pathresolve.ts +102 -20
  62. package/src/explain/quoted-string.ts +41 -6
  63. package/src/explain/run-tokens.ts +223 -0
  64. package/src/explain/scope-brace.ts +103 -6
  65. package/src/explain/segment.ts +41 -3
  66. package/src/explain/spaced-args.ts +116 -0
  67. package/src/explain/symbols.ts +17 -1
  68. package/src/explain/transport.ts +13 -0
  69. package/src/explain/verbsplit.ts +14 -188
  70. package/src/explain/write.ts +8 -1
  71. package/src/explain.ts +378 -18
  72. package/src/index.ts +18 -1
  73. package/src/mcp/safety.ts +11 -0
  74. package/src/mcp/tools.ts +4 -1
  75. package/src/offline-gate.ts +30 -9
  76. package/src/protocols/btest-session.ts +30 -10
  77. package/src/protocols/mac-telnet-console.ts +16 -0
  78. package/src/resolver/cdb.ts +13 -1
  79. package/src/resolver/index.ts +4 -0
  80. package/src/resolver/selection.ts +37 -1
  81. package/src/resolver/target.ts +96 -8
  82. package/src/retrieve-fanout.ts +4 -1
  83. package/src/retrieve-follow.ts +29 -33
  84. package/src/retrieve-sample.ts +14 -8
  85. package/src/retrieve-stream.ts +38 -6
  86. package/src/retrieve.ts +73 -59
  87. package/src/transfer-fanout.ts +8 -1
  88. package/src/transfer.ts +15 -19
@@ -483,7 +483,8 @@ REST body; the comment text is never rendered as an operand.
483
483
 
484
484
  The CHR assertion grounds the wider placement rule on the requested RouterOS
485
485
  channel via `/console/inspect request=highlight` plus `:parse`: unquoted `#`
486
- inside brace arrays and parenthesized expressions is a hard error; the first
486
+ inside brace arrays and parenthesized expressions is a hard error, except at
487
+ column 0 inside an array (example 37); the first
487
488
  non-space `#` in `do`, `else`, `foreach do`, and `on-event` bodies that are
488
489
  themselves in a statement context is a comment — the same `do={ # c` nested
489
490
  inside an array is an array and `#` is an error (placement table, #249), while
@@ -700,3 +701,261 @@ envelope would otherwise have to derive the name/value boundary from
700
701
  The same command also shows the two `=` bytes staying apart: the one in
701
702
  `($x = 1)` is a comparison and comes back `operator`, decided by fill order
702
703
  rather than by a scan for the byte.
704
+
705
+ ### 33. A space for the `=` is not an empty argument list
706
+
707
+ ```bash
708
+ centrs explain '/ip/firewall/layer7-protocol add comment "something" regexp 1.1' --json
709
+ ```
710
+
711
+ RouterOS accepts this as `comment="something" regexp=1.1`: `:parse` lowers both
712
+ spellings to the same IL, and `highlight` marks each gap `syntax-obsolete`.
713
+ Which word is an argument name is per-command schema, so offline publishes
714
+ neither reading. The statement stays `resolved` on
715
+ `/ip/firewall/layer7-protocol add`, `command.args` is absent, `arguments.read`
716
+ is `false` with a `why` naming `` `comment "something"` ``, and
717
+ `transport.classification` stays `"unknown"`.
718
+
719
+ `data.verdict` is `warn`, with one
720
+ `explain/canonicalizer/possible-spaced-argument` **warning** per pair,
721
+ spanning `comment "something"` and `regexp 1.1`. Each says to write the `=`.
722
+ The warning needs a value that cannot be a flag: `/interface set ether1 name x`
723
+ also abstains but does not warn, because `/interface print detail
724
+ without-paging` is the same shape and is two flags
725
+ (`detail=true;without-paging=true`). The `=` spelling still reads both
726
+ arguments, and a script body may drop its `=` too:
727
+ `/system/script add name=beep source { :beep }` is the `source={ :beep }` body,
728
+ not an invalid brace.
729
+
730
+ ### 34. An unlisted bare path does not become a menu
731
+
732
+ ```bash
733
+ centrs explain $'/terminal/cuu\nprint\nadd name=x' --json
734
+ ```
735
+
736
+ `/terminal/cuu` is a no-argument command that neither the menu table nor the
737
+ command catalog lists. `/ip fire conn` has the same shape and is navigation:
738
+ RouterOS accepts abbreviated menus. CHR 7.24.5 `:parse` confirms both, so a
739
+ table miss cannot decide between a menu and a command.
740
+
741
+ The first statement stays `ambiguous`. The two after it are `unknown`
742
+ (`context lost to an earlier unreadable statement`, `contextCertain: false`),
743
+ with no `command` and no `transport`, where they used to resolve to an
744
+ invented `/terminal/cuu/print` and `/terminal/cuu/add`. On the device they are
745
+ `bad command name print` and `bad command name add` at the root.
746
+ `structure.containsWrite` is `unknown` and `data.verdict` is `warn`. A known
747
+ absolute navigation recovers the context: in
748
+ `/terminal/cuu`, `/ip address`, `print`, the last statement resolves to `print`
749
+ at `/ip/address` again.
750
+
751
+ ### 35. A brace after a command is its argument, not a menu block
752
+
753
+ ```bash
754
+ centrs explain '/system/script add name beep source { :beep }' --json
755
+ ```
756
+
757
+ `/menu { … }` opens a menu block only when the words before the brace name a
758
+ menu. Here `add` is a command, so the brace is the value of `source` with the
759
+ `=` left out. CHR 7.22.1 and 7.24.5 `:parse` give the same IL as
760
+ `source={ :beep }`.
761
+
762
+ `data.structure.statements` has one statement, `/system/script add`, whose
763
+ span covers the whole input. `:beep` is no longer published as a second
764
+ top-level statement, which RouterOS never runs. `data.verdict` is `warn`, from
765
+ the `possible-spaced-argument` diagnostic on `source { :beep }`.
766
+
767
+ RouterOS accepts any unique prefix of an argument name. An abbreviation that
768
+ can only stand for a script-valued name keeps its body in either spelling:
769
+ `sour { :beep }`, `down-scr { :beep }` and `o={ :beep }` lower to `source=`,
770
+ `down-script=` and `on-event=` on CHR 7.24.5 and 7.23.8. Abbreviating the first
771
+ segment (`down={ :beep }` for `down-script`) is accepted on the device too, but
772
+ offline still reports `invalid-command-brace`. Every word is a prefix of some
773
+ `<word>-script`, so telling it from `comment={ :beep }` needs the command's
774
+ argument list.
775
+
776
+ A brace straight after a command is a device error, and offline now reports it.
777
+ `/system/script add { :beep }`, `/ip/route/print {1;2}` and
778
+ `/system/reboot { :beep }` each get `invalid-command-brace` and `data.verdict`
779
+ `fail`. A run that names no command still opens a block, so
780
+ `/system script { print }` is navigation to `/system/script` followed by
781
+ `print`.
782
+
783
+ ### 36. A brace in a `where` clause is an array value
784
+
785
+ ```bash
786
+ centrs explain '/ip address print where interface in {"ether1";"ether2"}' --json
787
+ ```
788
+
789
+ A `where` clause is expression context, so the brace is the array operand of
790
+ `in`. CHR 7.24.5 and 7.23.8 `:parse` lower it to
791
+ `(in $interface ether1;ether2)`. `data.verdict` is `pass`, so `centrs execute`
792
+ runs this read. Before #502 its offline stage refused it with
793
+ `invalid-command-brace`. `data.values.occurrences` holds the array and its two
794
+ string members, the same three records as
795
+ `where interface in ("ether1","ether2")`.
796
+
797
+ The brace is also a value after `~`, `<`, `>`, `<=` and `>=`, on the left of an
798
+ operator (`where {"ether1"} in interface`), and after `!interface=` or `"a"=`.
799
+
800
+ After `<name>=`, `<name>!=` or `<name>==`, spaced or not, the device reads the
801
+ value with that property's parser and rejects the brace. Two examples are
802
+ `where interface={"ether1"}` (`expected interface value`) and
803
+ `where disabled={"y"}` (`expected yes or no`). Offline keeps
804
+ `invalid-command-brace` at the `{` there. The device does this only for a
805
+ property the menu knows: `where foo-bar={"y"}` parses. Offline cannot tell
806
+ property names apart without a per-menu list, so it reports that one too.
807
+
808
+ ### 37. A column-0 `#` line inside an array is a comment
809
+
810
+ ```bash
811
+ centrs explain ':local z {a=1;
812
+ # c
813
+ b=2}' --json
814
+ ```
815
+
816
+ `data.verdict` is `pass`. `data.spans` holds a `comment` span over `# c`, and
817
+ `data.values.occurrences` holds the array and its two members `a` and `b`. CHR
818
+ 7.24.5 and 7.22.1 `:parse` lower it to `value=a=1;b=2`. Before #503, offline
819
+ failed it with `invalid-hash` at the `#`.
820
+
821
+ The rule holds at any depth (`{a={⏎# c⏎1}}`) and in argument values
822
+ (`:foreach i in={1;⏎# c⏎2} do={:put $i}`). It needs column 0: an indented `#`
823
+ (`{a=1;⏎ # c⏎b=2}`), and one after `{`, a value or `;` on the same line, keep
824
+ `invalid-hash` at the `#`. A `#` directly inside `( … )` stays an error too. A
825
+ comment-only array, `:local z {⏎# c⏎}`, fails like `:local z {}`: both are empty
826
+ arrays, which the device rejects (#511, example 38).
827
+
828
+ ### 38. An empty array value and a misplaced `;` are rejected
829
+
830
+ ```bash
831
+ centrs explain ':local z {}' --json
832
+ ```
833
+
834
+ `data.verdict` is `fail` with `explain/canonicalizer/empty-group` over `{}`. CHR
835
+ 7.24.5 and 7.23.8 `:parse` answer `syntax error (line 1 column 12)`, the byte
836
+ after the brace. The same holds for `:put {}`, `:return {}`, `[:len {}]` and
837
+ `:foreach i in={} do={…}`. A nested `{}` (`{1;{}}`), one in parentheses
838
+ (`({})`), `where … in {}` and a body (`do={}`, `:execute {}`) parse and pass.
839
+
840
+ `:local z {1⏎;2}` fails with `misplaced-separator` at the `;`: a `;` needs an
841
+ element before it on its line. `{1;}` and `{1⏎2}` pass.
842
+
843
+ ### 39. An expression with a missing operand is rejected
844
+
845
+ ```bash
846
+ centrs explain ':put (1 +)' --json
847
+ ```
848
+
849
+ `data.verdict` is `fail` with `explain/canonicalizer/missing-operand` at the
850
+ `+`. The device answers `syntax error (line 1 column 11)`, at the `)`. `()`
851
+ fails with `empty-group`, and `(1; 2)` and `(1⏎+ 2)` fail with
852
+ `misplaced-separator`. `(1 2)`, `(- 1)` and `(1 + -2)` pass. Inside a query,
853
+ `where (comment=+)` passes: the device reads that value with the property's
854
+ parser.
855
+
856
+ ### 40. A `@` in a command word is rejected
857
+
858
+ ```bash
859
+ centrs explain '[admin@MikroTik] > /ip address print' --json
860
+ ```
861
+
862
+ `data.verdict` is `fail` with `explain/canonicalizer/invalid-at-sign` at the
863
+ `@`. The device answers `syntax error (line 1 column 7)`, the same byte. This
864
+ is a pasted console prompt. `:put a@b` and `comment=a@b` pass, because a `@` is
865
+ a value byte in an argument.
866
+
867
+ ### 41. A named comparison brace inside a `[find where …]` is rejected
868
+
869
+ ```bash
870
+ centrs explain ':put [/ip address find where interface={"x"}]' --json
871
+ ```
872
+
873
+ `data.verdict` is `fail` with `explain/canonicalizer/invalid-command-brace` at
874
+ the `{`, as at the top level (example 36). The device answers
875
+ `expected interface value (line 1 column 40)`. `where (interface={"x"})` fails
876
+ the same way, and `[find where interface in {"x"}]` passes.
877
+
878
+ ### 42. A stray top-level `}` ends the script, and the rest is discarded
879
+
880
+ ```bash
881
+ centrs explain ':put 1};:put 2' --json
882
+ ```
883
+
884
+ `data.verdict` is `warn` with `explain/canonicalizer/discarded-close` over the
885
+ `}` and everything after it. CHR 7.24.5 and 7.23.8 accept the script and lower
886
+ it to `(evl /putmessage=1)`: the device stops reading at a `}` or `]` that closes
887
+ nothing at the top level, so `:put 2` never runs and
888
+ `/system identity set name=a}` sets `a`. `data.structure` reads only the
889
+ prefix. A stray close inside an open group (`:put (1})`, `{:put 1]}`) and any
890
+ stray `)` are still rejections and still fail with `unbalanced-close`. The
891
+ execute gate refuses this warning (`commands/execute/examples.md` V9).
892
+
893
+ ### 43. A variable name is letters and digits
894
+
895
+ ```bash
896
+ centrs explain ':local a_b 1' --json
897
+ ```
898
+
899
+ `data.verdict` is `fail` with `explain/canonicalizer/invalid-name` at the `_`.
900
+ The device answers `expected end of command (line 1 column 9)`, the same byte.
901
+ `:global`, `:set`, `:for` and `:foreach` read the name the same way, and
902
+ `:local $a` and `:local (a)` fail at their first byte. `:local "a_b"`,
903
+ `:local 1a`, `:foreach a,b in={1} do={}` and `:local name=x value=1` pass.
904
+
905
+ ### 44. A `$` must start a variable
906
+
907
+ ```bash
908
+ centrs explain ':put "a$ b"' --json
909
+ ```
910
+
911
+ `data.verdict` is `fail` with `explain/canonicalizer/bad-string-variable` over
912
+ the `$` and the space after it. The device answers `syntax error (line 1 column 9)`, at the space. Outside
913
+ a string, `:put $_` fails with `invalid-variable` the same way. `"$v_x"` (which
914
+ is `$v` then `_x`), `"\$ x"`, `"$(1)"` and `$"a b"` pass. `"a$"` also passes,
915
+ although the device rejects it: it reads `$"` as the start of a quoted name, and
916
+ offline's string scanners end the string there, so offline abstains.
917
+
918
+ ### 45. Nothing may be glued to a closed token
919
+
920
+ ```bash
921
+ centrs explain ':if (true) do={:put 1}else={:put 2}' --json
922
+ ```
923
+
924
+ `data.verdict` is `fail` with `explain/canonicalizer/glued-token` at the `e`.
925
+ The device answers `expected end of command (line 1 column 23)`. The same rule
926
+ rejects `:put "a"b`, `$v.b`, `(1)(2)`, `[:len a]b`, `src-address=$v/24` and a
927
+ bare word run into a `"`, `$` or bracket (`:put a"b"`, `name=a$v`). Inside
928
+ `where` and `find`, and inside a group, the rule does not apply (`where
929
+ comment="a"b` and `("a"b)` parse). A command argument whose property parser
930
+ takes the glued part also passes: `dst-address-list=!"my list"` and
931
+ `dst-port=80,"443"`.
932
+
933
+ ### 46. Statement ids and source ownership
934
+
935
+ ```bash
936
+ centrs explain ':if (true) do={ :foreach i in={1;2} do={ :put $i } } else={ :put 0 }; :put 3' --json
937
+ ```
938
+
939
+ The statements keep discovery order and have ids `s0` through `s4`.
940
+ The `foreach` (`s1`) has `parent: "s0"`; its `put` (`s2`) has `parent: "s1"`;
941
+ the `else` body's `put` (`s3`) has `parent: "s0"`. The final `put` (`s4`) has
942
+ no parent. The published `do` and `else` blocks both have `owner: "s0"`;
943
+ `s1` and `s2` share `block: 0`, and `s3` has `block: 1`. A `[find]` inside a
944
+ body names that body's statement as its `owner`. Links describe source
945
+ ownership; binding and execution-context claims remain separate (#507).
946
+
947
+ ### 47. A stored script stays opaque
948
+
949
+ ```bash
950
+ centrs explain '/system/script/add name=proof source={ :local x 1; :put $x }; :put 2' --json
951
+ ```
952
+
953
+ Only the installing command (`s0`) and final `put` (`s1`) appear in
954
+ `statements[]`; neither has a parent, and `blocks[]` stays empty. The deferred
955
+ body's local and `put` are not exposed as statements. Scheduler `on-event=`
956
+ and Netwatch `down-script=` have the same structural boundary.
957
+
958
+ The CHR controls in `test/integration/explain-structure.test.ts` parse the
959
+ scope examples and compare stored-script creation, explicit `run`, and an
960
+ immediate `:do` body. Those are test operations on disposable CHR; explain
961
+ itself never executes the analyzed input.
@@ -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
 
@@ -58,6 +58,10 @@ is unsafe the offline stage is what keeps the gate from degrading to nothing. A
58
58
  clean offline pass is **necessary, never sufficient** — `explain` publishes
59
59
  `runtimeAcceptance: "not-proven"` for exactly this reason. An offline `warn` is
60
60
  an ABSTENTION and passes through to the device stage; only a `fail` rejects.
61
+ The one exception is `discarded-close`: a stray top-level `}` or `]` that the
62
+ device accepts and then silently stops reading at (#504). It is a warning,
63
+ because nothing is rejected, and the gate refuses it by code
64
+ (`src/offline-gate.ts`), because the device would run a different script.
61
65
 
62
66
  `meta.validation.stages[]` reports what each stage concluded, including a stage
63
67
  that was skipped and why. A reader must always be able to tell "the device
@@ -177,8 +181,29 @@ Notes:
177
181
  lossless rule applies to **stdout** only: **stderr** carries progress and
178
182
  interactive prompts (discover scan progress, write/ambiguity confirmation) and
179
183
  is exempt — use it sparingly, since most operations are fast.
184
+ - **Text stdout is data** (#470). In text mode a single-target `execute`,
185
+ `api`, `retrieve` or `transfer` writes only `data` (or its error) to stdout.
186
+ The notes around it go to stderr: the `--verbose` header first, then one
187
+ `warning [<code>]: <message>` line per warning, then the `Tips:` footer, on
188
+ success and on failure alike. `transport/provider-trust` is left out of text
189
+ because it rides every quickchr run over TLS or SSH. `--json` keeps
190
+ everything, `api --raw` stays a bare payload, and fan-out text is a report,
191
+ not data, so it keeps its warnings on stdout. A text stream writes only its
192
+ data rows (`frame`, `sample`) to stdout; its `started`, `synced` and
193
+ `summary` lines, tips, warnings and errors go to stderr (#473).
180
194
  - `data` is the only field whose *shape* varies by command. Everything else —
181
195
  including `warnings` and `tips` — is invariant in shape; only contents differ.
196
+ - **Shared meta is written once** (#461, #462). A fan-out target's
197
+ `meta.settings` keeps only the keys whose source differs from the outer
198
+ `meta.settings`, and its `meta.operation` drops `request` and `auth` when
199
+ they equal the outer operation's. When every target agrees on `auth`, or
200
+ on a setting the outer `meta.settings` lacks, it moves to the outer
201
+ envelope. A reader takes the target's value, else
202
+ the outer one (`expandFanoutTarget`). A stream (`retrieve --follow`,
203
+ `retrieve --sample`, `api --stream`) opens with a `started` line that
204
+ carries the full meta and any request advice in `tips`, and ends with a
205
+ full-meta `summary` (`--follow`'s `synced` is full too). The data lines
206
+ between (`frame`, `sample`) carry only `meta.operation` (#473).
182
207
 
183
208
  ## Error model
184
209
 
@@ -304,8 +329,10 @@ Fan-out output is the locked **`FanoutData`** envelope: `data = { summary, targe
304
329
  is an inner `ok:false` envelope (output, not metadata). `targets[]` is reassembled
305
330
  in resolved record order, not completion order, so repeated runs diff cleanly. The
306
331
  **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.
332
+ target ok (or an empty selection for a read), `2` partial (some ok, some
333
+ failed), `1` orchestration error or every target failed. An empty selection is
334
+ a warning for a read and an orchestration error for a write, `--yes` or not,
335
+ so a mistyped selector never passes for a successful bulk write (#460).
309
336
 
310
337
  A multi-target **RouterOS write** (e.g. `execute` / `api` add/set/remove across a
311
338
  selection) is gated by **`--yes`**, confirmed **once** up front; when it is missing
package/docs/MATRIX.md CHANGED
@@ -182,16 +182,26 @@ Wire, auth, TCP/UDP, and CI caveats live in `commands/btest/README.md` and the
182
182
 
183
183
  ## Current priority
184
184
 
185
- **First: the CMR track** (maintainer decision 2026-10-12 on
186
- [#441](https://github.com/tikoci/centrs/issues/441)). Done: `execute --cmr`
185
+ **First: `explain`** (maintainer decision 2026-10-16 on
186
+ [#90](https://github.com/tikoci/centrs/issues/90)). In order: make the
187
+ published offline readings honest (#350, #211), give the result a statement
188
+ tree, then the live probe foundation (#491). Then use those probes to
189
+ ground #351, #358 and #371, and finish live (#492 → #236 closeout). The
190
+ plan, and what is deliberately out of this run, is the 2026-10-16 plan
191
+ comment on #90. It is step 3 of the validation track below.
192
+
193
+ **The CMR track is paused until RouterOS 7.26beta2** (maintainer decision
194
+ 2026-10-16). It was first from the 2026-10-12 decision on
195
+ [#441](https://github.com/tikoci/centrs/issues/441). Done: `execute --cmr`
187
196
  (#450), which runs one validated command on CMR clients through their
188
197
  controller. The contract is in
189
198
  [`commands/execute/README.md` → CMR](../commands/execute/README.md#cmr-clients-through-their-controller---cmr),
190
- with examples CMR0–CMR14. The CMR-vs-centrs feature map is on #441
191
- (2026-10-13). Next, in order: the fleet blast-radius gate for controller fleet
192
- commands (#453), then #449's read-only slice (a `devices` view of members and a
193
- default controller). Deferred: CDB linkage (#449) and `retrieve --cmr` (#441).
194
- Live `explain` resumes after this.
199
+ with examples CMR0–CMR15. Also done: the fleet blast-radius gate for
200
+ controller fleet commands run with plain `execute` or `api` (#453), with
201
+ examples CMR16–CMR18 and api CMR1. The feature map of CMR against centrs is
202
+ on #441 (2026-10-13). When it resumes, next is the read-only slice of #449 (a
203
+ `devices` view of members and a default controller). Deferred: CDB linkage
204
+ (#449) and `retrieve --cmr` (#441).
195
205
 
196
206
  **The native-api / NDJSON line** (maintainer decision on
197
207
  [#396](https://github.com/tikoci/centrs/issues/396), 2026-10-07): agents in the
@@ -229,16 +239,19 @@ producers owned with #225. REST and native-api cells advance only when their
229
239
  applicable examples pass on CHR via `bun run test:integration`.
230
240
 
231
241
  Open offline follow-ups remain explicit but do not reopen the verified baseline.
232
- Issue #211 retains the fail-closed unlisted-path decision; #272 owns
242
+ Issue #211 made an unlisted absolute bare path abstain (rule F, R14); rule
243
+ P's closed-world reading is still not adopted (#372). #272 owns
233
244
  non-authoritative `.scratch/` citation cleanup. #350 (the broad optional `=`,
234
245
  schema-dependent and so belonging to step 3), #351 (an unresolvable menu that
235
246
  abstains at 2-deep nesting but hard-fails at 3-deep) and #355 (`'` is not a
236
247
  RouterOS token — a false ACCEPT the corpus join cannot see) are bounded analyzer
237
- defects that step 2 now surfaces through the `execute`/`api` gate. #336 and #342 are complete: the
238
- corpus is repinned to three *complete* builds (long-term 7.23.5, stable 7.24.2,
239
- development 7.25beta3), and the operator sweep and highlight slice are re-swept
240
- onto those same builds — plus 7.21.5 retained as an age anchor — so the operator
241
- axis and the token partition are scored on the same firmware. The #335
248
+ defects that step 2 now surfaces through the `execute`/`api` gate. #336, #342 and #498 are complete: the
249
+ corpus is pinned to schema 5, with one recorded session per release channel
250
+ (long-term 7.23.8, stable 7.24.5, testing 7.25rc2, development 7.26beta1) read
251
+ through `scripts/corpus-db.ts`. The highlight slice is cut from those sessions
252
+ and gated in CI, and the operator sweep is re-run on the same builds — plus
253
+ 7.21.5 retained as an age anchor — so the operator axis and the token partition
254
+ are scored on the same firmware. The #335
242
255
  source refresh pins both architectures of those versions beside the 7.10.2 /
243
256
  7.16 history anchors and accepts the current CLI-Reference drift. Issue #223 is
244
257
  a separate MCP response migration. If live evidence finds a wrong offline