@tikoci/centrs 0.1.4 → 0.1.7

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 (85) hide show
  1. package/CHANGELOG.md +417 -1
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +16 -8
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +50 -10
  7. package/commands/api/examples.md +100 -2
  8. package/commands/devices/README.md +10 -1
  9. package/commands/devices/examples.md +33 -2
  10. package/commands/execute/README.md +68 -25
  11. package/commands/execute/examples.md +168 -7
  12. package/commands/explain/README.md +1206 -155
  13. package/commands/explain/examples.md +99 -3
  14. package/commands/mcp/README.md +9 -5
  15. package/commands/mcp/examples.md +2 -2
  16. package/commands/retrieve/examples.md +20 -0
  17. package/commands/terminal/examples.md +7 -1
  18. package/docs/CLI.md +3 -3
  19. package/docs/CONSTITUTION.md +83 -21
  20. package/docs/MATRIX.md +89 -52
  21. package/docs/errors/cdb/create-failed.md +20 -0
  22. package/docs/errors/cdb/file-exists.md +23 -0
  23. package/docs/errors/input/invalid-target.md +25 -3
  24. package/docs/errors/routeros/invalid-value.md +3 -2
  25. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  26. package/docs/errors/validation/syntax.md +29 -4
  27. package/package.json +13 -2
  28. package/src/api-fanout.ts +17 -4
  29. package/src/api.ts +346 -75
  30. package/src/cli/api.ts +1 -1
  31. package/src/cli/btest.ts +1 -1
  32. package/src/cli/devices.ts +16 -3
  33. package/src/cli/explain.ts +1 -1
  34. package/src/cli/terminal.ts +1 -1
  35. package/src/core/envelope.ts +53 -0
  36. package/src/core/error-catalog.ts +16 -1
  37. package/src/core/execute-command.ts +455 -0
  38. package/src/core/inspect.ts +58 -26
  39. package/src/core/routeros-errors.ts +66 -0
  40. package/src/core/version.ts +4 -0
  41. package/src/core/yaml.ts +86 -0
  42. package/src/data/winbox-cdb-write.ts +22 -8
  43. package/src/devices.ts +142 -28
  44. package/src/execute-fanout.ts +19 -5
  45. package/src/execute.ts +247 -295
  46. package/src/explain/arg-tokens.ts +62 -25
  47. package/src/explain/args.ts +398 -52
  48. package/src/explain/blocks.ts +54 -11
  49. package/src/explain/brace-tokens.ts +4 -3
  50. package/src/explain/catalog.ts +42 -44
  51. package/src/explain/coordinates.ts +159 -0
  52. package/src/explain/defects.ts +2 -1
  53. package/src/explain/escape-tokens.ts +77 -0
  54. package/src/explain/highlight-projection.ts +308 -0
  55. package/src/explain/menus.ts +9 -5
  56. package/src/explain/operator-tokens.ts +13 -7
  57. package/src/explain/operators.ts +27 -20
  58. package/src/explain/path-tokens.ts +1 -1
  59. package/src/explain/pathresolve.ts +358 -113
  60. package/src/explain/quoted-string.ts +110 -20
  61. package/src/explain/scope-brace.ts +175 -22
  62. package/src/explain/segment.ts +429 -21
  63. package/src/explain/separator.ts +187 -0
  64. package/src/explain/split-owner.ts +194 -0
  65. package/src/explain/string-tokens.ts +14 -4
  66. package/src/explain/symbol-values.ts +209 -173
  67. package/src/explain/symbols.ts +21 -1
  68. package/src/explain/transport.ts +17 -0
  69. package/src/explain/value-tokens.ts +15 -12
  70. package/src/explain/verbsplit.ts +165 -23
  71. package/src/explain/write.ts +47 -6
  72. package/src/explain.ts +522 -72
  73. package/src/index.ts +21 -2
  74. package/src/mcp/server.ts +2 -1
  75. package/src/mcp/tools.ts +1 -1
  76. package/src/offline-gate.ts +148 -0
  77. package/src/protocols/adapter.ts +12 -1
  78. package/src/protocols/mac-telnet-console.ts +6 -4
  79. package/src/protocols/native-api.ts +29 -3
  80. package/src/resolver/target.ts +32 -12
  81. package/src/retrieve-fanout.ts +1 -1
  82. package/src/retrieve.ts +18 -63
  83. package/src/settings.ts +2 -1
  84. package/src/transfer-fanout.ts +1 -1
  85. package/src/transfer.ts +1 -1
@@ -99,19 +99,51 @@ vs `--query` distinction.
99
99
 
100
100
  ## Validation
101
101
 
102
- Default on. Because the input is a structured path (not a CLI string), the gate is
103
- `/console/inspect` — **not** `:put [:parse]`:
102
+ Default on. Because the input is a structured path (not a CLI string), the device
103
+ gate is `/console/inspect` — **not** `:put [:parse]`:
104
104
 
105
105
  - Path existence + per-verb attribute validity via `request=child`
106
106
  (`validation/unknown-path`, `validation/unknown-attribute`).
107
- - Value-level advice via `request=completion` (+`input=`) feeding `tips`/`warnings`.
107
+ - **Designed, not implemented:** value-level advice via `request=completion`
108
+ (+`input=`) feeding `tips`/`warnings`. Whatever implements it must not fall
109
+ back to `request=completion` at a *command* path (`path=` ending at the verb):
110
+ that shape hangs REST and closes native API on RouterOS 7.12.2, and no centrs
111
+ code uses it. Completion at an *argument* path (`print,proplist`, as `retrieve`
112
+ does) answers normally on the same build. See the 7.12.2 finding in
113
+ `AGENTS.md` (GH#343).
108
114
  - **Carve-out:** a script-shaped `POST /rest/execute` is a CLI string, not a path,
109
- so the inspect gate is `not-applicable` (`meta.validation.semantic`); RouterOS
110
- re-validates on the run.
111
-
112
- `--validate=false` (and `--raw`, which implies it) skip the preflight; RouterOS
113
- still re-validates writes server-side. Disabling validation to make a call pass is
114
- forbidden (constitution: validation is the product).
115
+ so structured inspect is `not-applicable` (`meta.validation.semantic`). It
116
+ follows the CLI-string pipeline instead: offline analysis, then live
117
+ `:put [:parse ...]`, then run. An offline rejection is still a byte span with
118
+ no connection; an offline pass or abstention continues to the device stage.
119
+ `meta.validation.stages[]` reports both decisions. A missing or blank `script`
120
+ is still `input/invalid-command`, not a syntax rejection. Validation passing
121
+ is not the run succeeding: `as-string` makes the console's own text the reply
122
+ body, so a runtime rejection arrives as an HTTP-200 `ret`. `/execute` output
123
+ is normalized through the same grounded result table `execute` uses, so
124
+ `no such item` fails the envelope with a `routeros/*` code while the stages
125
+ stay `passed`. Ordinary output that merely contains a fault string is
126
+ unaffected — the result matchers are anchored, not substring.
127
+ - Structured path requests are **not** offline-gated. The input is a path plus a
128
+ body, not a CLI string, and an offline *path* gate collides with GH#211's
129
+ unlisted-path decision. On the success path `meta.validation.stages[]` is
130
+ absent there; on a `validation/*` rejection it is present and reports the
131
+ offline stage `skipped` with that reason, so the breakdown never implies an
132
+ analysis that did not run.
133
+
134
+ `--validate=false` skips **both** stages of the preflight — one flag, one meaning
135
+ (constitution: validation); RouterOS still re-validates writes server-side.
136
+ Disabling validation to make a call pass is forbidden (constitution: validation
137
+ is the product).
138
+
139
+ `--raw` **defaults** `--validate` to false — it does not override it (GH#154).
140
+ The resolution order is the explicit `--validate` flag, then `--raw`, then
141
+ `CENTRS_VALIDATE` / the CDB comment-kv / config, then the `true` default. So
142
+ `--raw` alone behaves as it always has and behaves the same on every machine
143
+ whatever the environment holds, while `--raw --validate=true` runs the gate and
144
+ reports a rejection in the `--raw` error shape (`{code,message}` on stderr,
145
+ nonzero exit, no `data` key) — which is how you debug `api` when centrs itself is
146
+ the suspect.
115
147
 
116
148
  ## Write confirmation
117
149
 
@@ -157,7 +189,15 @@ RouterOS produces multi-frame output two ways, and `api` treats them differently
157
189
  deletion's `.dead=true` flag is preserved); the stream ends with a summary
158
190
  envelope (`data.stopReason` ∈ `count-reached`/`duration-elapsed`/`interrupted`/
159
191
  `transport-error`, plus `frames` and `durationMs`). `--duration`/`--count`
160
- bound it; Ctrl-C stops and still emits the summary. `--via rest-api --stream` →
192
+ bound it; Ctrl-C stops and still emits the summary. The `--duration` window
193
+ starts once validation is done, so it excludes validation (and the connect and
194
+ login validation needs). With `--no-validate`, connect and login happen inside
195
+ the window. Stopping on `--duration` or Ctrl-C sends RouterOS `/cancel`, and
196
+ centrs waits up to `--timeout` for the router to acknowledge it. A router that
197
+ never does gets its session closed locally, and the summary carries a
198
+ `transport/cancel-unacknowledged` warning (#385). Reaching `--count` sends
199
+ `/cancel` best-effort and closes the session without waiting, so it never
200
+ carries that warning (examples L5). `--via rest-api --stream` →
161
201
  `transport/capability-unsupported`. The exit code reflects whether the stream
162
202
  *started* cleanly, not whether every frame was `ok`.
163
203
 
@@ -155,7 +155,49 @@ centrs api $R execute -X POST -f script=':put [/system/identity/get name]' --use
155
155
  ```
156
156
 
157
157
  Envelope: `ok: true`, `data` is string-shaped and contains the CHR identity;
158
- `meta.validation.semantic=not-applicable` (script, not a path).
158
+ `meta.validation.semantic=not-applicable` (script, not a path) and
159
+ `meta.validation.stages` is `offline: passed` then live `device: passed` from
160
+ `:put [:parse ...]`.
161
+
162
+ ### 16b. The `/execute` script is gated offline (GH#354)
163
+
164
+ Until GH#354 the script-mode carve-out meant this surface ran **no** preflight at
165
+ all. The offline analyzer is the whole stage-1 gate here, and it opens no
166
+ connection — the unreachable port proves it.
167
+
168
+ ```bash
169
+ centrs api 127.0.0.1:1 execute -X POST -f script=':put [' --username $U --password $P --yes
170
+ ```
171
+
172
+ Envelope: `ok: false`, `error.code=validation/syntax`,
173
+ `error.context.validationStage="offline"`, `error.context.surface="api /execute"`,
174
+ `error.context.span={"start":5,"end":6}`, and **not** a `transport/*` code.
175
+ `meta.validation.stages` is `offline: failed` then `device: skipped`.
176
+
177
+ ### 16c. Offline pass continues to live `:parse`
178
+
179
+ ```bash
180
+ centrs api $R execute -X POST -f script='/ip/address/add no-such-arg=x' --username $U --password $P --yes
181
+ ```
182
+
183
+ Envelope: `ok: false` with a device validation code
184
+ (`validation/unknown-attribute`, or `validation/syntax` on older RouterOS), and
185
+ `meta.validation.stages` is `offline: passed` then `device: failed`. Offline
186
+ acceptance remains `runtimeAcceptance: not-proven`; it never suppresses the live
187
+ stage.
188
+
189
+ ### 16d. A runtime rejection in `/execute` output is not `ok: true`
190
+
191
+ ```bash
192
+ centrs api $R execute -X POST -f script='/ip/service/set www-ssl certificate=nope' --username $U --password $P --yes
193
+ ```
194
+
195
+ Envelope: `ok: false`, `error.code=routeros/invalid-value`. `as-string` makes
196
+ the console's own text the reply body, so RouterOS carries this rejection in an
197
+ HTTP-200 `ret`; the validation gate stays `passed` because it did pass — the
198
+ command was rejected at run, not at validation. Ordinary output that merely
199
+ mentions a fault string (`:put "status: no such item appears in help"`) stays
200
+ `ok: true`.
159
201
 
160
202
  ### 17. `--via rest-api --listen` is rejected
161
203
 
@@ -209,6 +251,20 @@ proplist). Combining an id with `--query`/`--proplist` folds the id into the RES
209
251
  matching the native `?.id=` read. (Plain id-only reads still use the `GET …/$ID`
210
252
  URL form; see example 5's re-read.)
211
253
 
254
+ ### 22. `--raw --validate=true` still runs the gate (#154)
255
+
256
+ ```bash
257
+ centrs api $R ip/no-such-menu --raw --validate=true --username $U --password $P
258
+ ```
259
+
260
+ `--raw` only **defaults** `--validate` to false, so an explicit `--validate=true`
261
+ wins and the `/console/inspect` preflight runs. The rejection comes back in the
262
+ `--raw` error shape, not the envelope: stdout empty, stderr a compact
263
+ `{"code":"validation/unknown-path","message":…}`, nonzero exit, and no `data`
264
+ key. Contrast example 13, where bare `--raw` skips the preflight entirely. This
265
+ pair is how you tell a RouterOS rejection from a centrs bug while staying on the
266
+ bare-passthrough surface.
267
+
212
268
  ## native-api (`--via native-api`)
213
269
 
214
270
  The same contract over the binary API. Validation still runs through
@@ -278,7 +334,28 @@ stdout is the bare rest-style array (string values); exit code 0.
278
334
  centrs api $A execute -X POST -f script=':put [/system/identity/get name]' --via native-api --port $API_PORT --username $U --password $P --yes
279
335
  ```
280
336
 
281
- Envelope: `ok: true`, `data` contains the CHR identity.
337
+ Envelope: `ok: true`, `data` contains the CHR identity and validation reports
338
+ offline plus live `:parse` stages passed.
339
+
340
+ ### N8b. Native `/execute` also rejects at live `:parse`
341
+
342
+ ```bash
343
+ centrs api $A execute -X POST -f script='/ip/address/add no-such-arg=x' --via native-api --port $API_PORT --username $U --password $P --yes
344
+ ```
345
+
346
+ Envelope: `ok: false`; the accepted version-specific validation code is
347
+ `validation/unknown-attribute` or `validation/syntax`, with stages
348
+ `offline: passed`, `device: failed`.
349
+
350
+ ### N8c. Native `/execute` runtime rejection fails the envelope
351
+
352
+ ```bash
353
+ centrs api $A execute -X POST -f script='/ip/service/set www-ssl certificate=nope' --via native-api --port $API_PORT --username $U --password $P --yes
354
+ ```
355
+
356
+ Envelope: `ok: false`, `error.code=routeros/invalid-value`, with the validation
357
+ stages still `passed` — the same normalization as REST, since both adapters
358
+ surface the `as-string` console text as `data`.
282
359
 
283
360
  ## listen / `--stream` (native-api only)
284
361
 
@@ -326,6 +403,27 @@ centrs api $A ip/address --stream --duration 2s --via native-api --port $API_POR
326
403
  With no change during the window, the stream ends after ~2 s with a summary
327
404
  envelope whose `data.stopReason=duration-elapsed`. Exit code 0.
328
405
 
406
+ ### L5. A router that ignores `/cancel` cannot hold the process open
407
+
408
+ ```bash
409
+ centrs api $A ip/address --stream --duration 200ms --timeout 1s --via native-api --port $API_PORT --username $U --password $P
410
+ ```
411
+
412
+ A stream stopped by `--duration` or Ctrl-C sends `/cancel` and waits up to
413
+ `--timeout` for the router's acknowledgement (the interrupted `!trap` and
414
+ `!done`). If none arrives, centrs closes the session itself. The summary still
415
+ reports its stop reason (`duration-elapsed` or `interrupted`), exit code is 0,
416
+ and `warnings` carries `transport/cancel-unacknowledged`. A router that answers
417
+ inside the window gets no warning. `--count` sends `/cancel` without waiting,
418
+ so it exits at once and never warns (#385).
419
+
420
+ RouterOS on CHR always acknowledges `/cancel`, so this example runs against a
421
+ loopback native-API peer that ignores it, as a real CLI process:
422
+ `test/integration/api-stream-cancel.test.ts` covers `--duration`, SIGINT,
423
+ `--count` and the cooperative control. The session-level cases (silent peer,
424
+ chatty peer, trap without `!done`, close after cancel) are in
425
+ `test/unit/native-api.test.ts`.
426
+
329
427
  ## fanout (multi-target, F…)
330
428
 
331
429
  These run against a CDB fixture with two records sharing group `$G`: record 0 is
@@ -332,6 +332,7 @@ runs produce stable diffs.
332
332
  ## Subcommands
333
333
 
334
334
  ```text
335
+ centrs devices init # create an empty CDB at the resolved path
335
336
  centrs devices list [--group G] [--where attr=value]
336
337
  [--near lat,lon,radius] [--bbox south,west,north,east]
337
338
  [--format json|yaml|text]
@@ -361,8 +362,16 @@ clack-style prompt used by `@tikoci/quickchr`); it is **not** a separate
361
362
  field-editing verb, and invoking it today returns `usage/not-implemented`. There
362
363
  is no `update`.
363
364
 
365
+ - `init` creates an empty open CDB at the resolved path (`--cdb-file`,
366
+ `CENTRS_CDB_FILE`, or the default), with parent directories and mode `0600`.
367
+ It never overwrites: on an existing, readable CDB it succeeds with a
368
+ `cdb/file-exists` warning and writes nothing. An explicit path is created only
369
+ by `init`; every other subcommand reports `cdb/not-found` for it (typo
370
+ protection), and that error names `init`.
364
371
  - `list` (alias `print`) shows resolved targets, their record type, group, and
365
- a one-line provenance summary. No network IO. `--where attr=value`
372
+ a one-line provenance summary. Each row carries the `identity=`/`mac=`/`ip=`
373
+ lookup keys it resolves by (when set), so one call maps names to records. No
374
+ network IO. `--where attr=value`
366
375
  (repeatable, AND-combined) filters by CDB-stored facts/comment-kv plus core
367
376
  record fields (`target`/`group`/`identity`/`mac`) — e.g. `--where
368
377
  lat=37.7749`. `--near lat,lon,radius` and `--bbox south,west,north,east`
@@ -29,7 +29,8 @@ centrs devices list --cdb-file $CDB
29
29
  ```
30
30
 
31
31
  Envelope: `ok: true`, `data` is an array of `{ target, recordType, group?,
32
- user, sources }`. No `meta.timing.request`.
32
+ user, identity?, mac?, ip?, sources }` — the lookup keys appear only when the
33
+ record's comment sets them. No `meta.timing.request`.
33
34
 
34
35
  ### 2. List a single group
35
36
 
@@ -46,7 +47,8 @@ valid success.
46
47
  centrs devices list --cdb-file /tmp/does-not-exist.cdb
47
48
  ```
48
49
 
49
- Errors with `cdb/not-found`. `details_url` populated.
50
+ Errors with `cdb/not-found`. `details_url` populated. The remediation names
51
+ `centrs devices init --cdb-file <path>` (example 54).
50
52
 
51
53
  ## show
52
54
 
@@ -634,3 +636,32 @@ Both succeed: the `add` stores the freeform comment verbatim, and the later
634
636
  `set timeout=5000` does **not** raise `input/incomplete-gps` even though the
635
637
  record's GPS pair is incomplete. Touching `lat`/`lon` (e.g. `set lat=…`) would
636
638
  re-enable the pairing check.
639
+
640
+ ## init
641
+
642
+ `init` is the explicit way to start a CDB at a path you choose (#376). Reads
643
+ and mutations never create an explicit `--cdb-file`/`CENTRS_CDB_FILE`; only the
644
+ default path is created implicitly on first run.
645
+
646
+ ### 54. Start a repo-local CDB
647
+
648
+ ```bash
649
+ centrs devices init --cdb-file ./lab/winbox.cdb --json
650
+ centrs devices add 192.0.2.1 --user robot-reader --cdb-file ./lab/winbox.cdb --json
651
+ ```
652
+
653
+ Creates missing parent directories and an empty open CDB with mode `0600`.
654
+ `data` is `{ cdbFile, created: true, recordCount: 0 }`. The `add` then succeeds.
655
+
656
+ ### 55. `init` never clobbers
657
+
658
+ ```bash
659
+ centrs devices init --cdb-file ./lab/winbox.cdb --json
660
+ ```
661
+
662
+ On an existing, readable CDB: `ok: true`, `data.created: false`,
663
+ `data.recordCount` is the current count, and a `cdb/file-exists` warning says
664
+ nothing was written. As with `git init`, re-running is safe. An existing file that
665
+ does not load fails with that load error (`cdb/parse-failed`,
666
+ `cdb/password-required`) and is left untouched. A location that cannot be
667
+ written fails with `cdb/create-failed`.
@@ -1,7 +1,7 @@
1
1
  # execute
2
2
 
3
3
  Run a RouterOS CLI command and return its (semi-structured) output. `execute`
4
- is the single read/write surface for RouterOS add/set/remove and other
4
+ is the single read/write surface for RouterOS configuration and operational
5
5
  CLI-shaped commands — there is no separate `update` command.
6
6
 
7
7
  Status: `rest-api`, `native-api`, `mac-telnet`, and `ssh` are `CHR-passed` (see
@@ -15,21 +15,45 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
15
15
 
16
16
  - Mirror RouterOS `execute` semantics. Input is a CLI string; output is
17
17
  console-shaped text (or structured records for path+verb writes) wrapped in
18
- the standard envelope. This includes write-shaped add/set/remove.
19
- - Validation is two-stage (see constitution: validation; grounded on CHR 7.23.3,
18
+ the standard envelope. This includes selector-, positional-id-, and
19
+ non-CRUD-shaped writes.
20
+ - Validation runs **offline first, then on the device** (see constitution:
21
+ validation; GH#354). Stage 1 is the corpus-gated analyzer over the exact
22
+ command string, with no connection: a syntax fault is rejected with a byte
23
+ span in `error.context.span` and the adapter is never dialed, and
24
+ `meta.validation.stages[]` then shows the device stage `skipped`. Stage 1
25
+ cannot see an unknown attribute — it has no per-menu schema — so stage 2 is
26
+ not optional.
27
+ - Stage 2 is itself two gates (grounded on CHR 7.23.3,
20
28
  GH#230): a single `:put [:parse "<cmd>"]` covers both the syntax and the
21
29
  unknown-attribute (name-level) gate on **every** transport — `:parse` never
22
30
  throws; its return value's printed form (`(evl …)` / `(evl bad parameter …)` /
23
- `(<%% bad command name …)`) is read from the REST/native `ret` *and* the
24
- console output. That check is necessary, not sufficient: value-type errors pass
25
- it, and the `/console/inspect` / server re-validation semantic checks are
26
- separate.
31
+ `(<%% bad command name …)`, or a bare line-anchored `syntax error` /
32
+ `expected …` / `missing … (line N column M)`, examples V4) is read from the
33
+ REST/native `ret` *and* the console output. An accepted return can echo
34
+ those words, so only the anchored forms reject. That check is necessary, not sufficient: value-type errors pass
35
+ it, and the `/console/inspect request=child` / server re-validation semantic
36
+ checks are separate. Command-level `request=completion` is deliberately not
37
+ used: it hangs REST and closes native API on RouterOS 7.12.2 (GH#343), while
38
+ `child` returns the complete argument-name set immediately.
27
39
  The outer RouterOS string literal escapes backslash, quote, and dollar so
28
40
  validation parses the caller's exact command rather than expanding variables
29
41
  in the wrapper.
30
- - centrs owns the **script-vs-structured gate** (`canonicalizeExecuteCommand` +
31
- `isWriteShaped` in `src/execute.ts`): it decides which validation runs and
32
- whether the write-confirm prompt fires. The shared `rosetta`/`lsp-routeros-ts`
42
+ - centrs owns the **script-vs-structured gate** (`canonicalizeExecuteCommand` in
43
+ `src/execute.ts`): it decides which validation and transport path runs.
44
+ `isWriteShaped` is deliberately independent: script fallback for `[find]`, a
45
+ positional id, or other non-`key=value` input never bypasses confirmation.
46
+ Known read verbs are allowlisted; an unknown menu verb fails closed as a
47
+ write-shaped operation. In script mode every command head in a statement
48
+ (the statement start and each `[…]` or `{…}` body) must be a read: its verb
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
56
+ `rosetta`/`lsp-routeros-ts`
33
57
  canonicalizer is for canonicalization only — never the structured predicate
34
58
  (widening `structured` is a product regression). Pinned by
35
59
  `test/unit/execute-canonicalize-contract.test.ts`; the parser-vendoring
@@ -59,12 +83,11 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
59
83
  else passes its `detail`/body plus the HTTP status through
60
84
  `mapRouterOsError({ transport: "rest-api", httpStatus })`. There is no longer
61
85
  a hand-rolled `routeros/*` table on the REST side.
62
- - **Run-time fault mapping — later (JG-28).** `mapRouterOsError`'s table covers
63
- the faults seen so far; a known gap is errors that *pass* `:parse`/validation
64
- but fail at **run time** (a syntactically valid command the router rejects when
65
- executed). `tikoci/vscode-tikbook` has an error-string mapping for these worth
66
- porting into `src/core/routeros-errors.ts`. Background research, not yet
67
- scheduled — captured here so it isn't lost (no GitHub issue by design).
86
+ - **Run-time fault mapping.** A successful transport can still carry a RouterOS
87
+ rejection in `/execute`'s `ret`. Result detection lives beside the grounded
88
+ mappings in `src/core/routeros-errors.ts`, uses anchored result shapes so
89
+ ordinary output containing error-like prose remains output, and preserves a
90
+ passed validation history while failing the overall envelope.
68
91
  - Output is *string*-shaped for script-mode; richer parsing is a future
69
92
  concern. The envelope must still distinguish RouterOS errors from successful
70
93
  runs (a 200 with a RouterOS error string is still an error — see
@@ -73,13 +96,33 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
73
96
  terminal width, so each transport handles width differently. **mac-telnet** is
74
97
  the only one that negotiates a size: `src/protocols/mac-telnet-console.ts`
75
98
  answers the device's terminal-size probe with a deliberately tall/wide screen,
76
- so captured columns are neither wrapped nor paginated. **SSH execute** runs
77
- *without* a pseudo-tty (`src/protocols/ssh.ts`), so RouterOS emits no
78
- terminal-wrapped output to begin with — there is nothing to negotiate.
79
- **REST/native-api** are not consoles, so terminal width never applies (they
80
- return structured records, or a string `ret` for script-mode). There is
81
- therefore no per-call `--width` knob today — an explicit width override is
82
- deferred until a concrete need appears.
99
+ so captured columns are neither paginated nor wrapped *at the negotiated
100
+ width*. **SSH execute** runs *without* a pseudo-tty (`src/protocols/ssh.ts`),
101
+ so no width is negotiated at all. **REST/native-api** are not consoles, so a
102
+ terminal width never applies (they return structured records, or a string
103
+ `ret` for script-mode). There is therefore no per-call `--width` knob today —
104
+ an explicit width override is deferred until a concrete need appears.
105
+ - **A device-side wrap that no width negotiation reaches (GH#352).** Declaring a
106
+ width is not sufficient, because RouterOS can wrap for reasons that are not
107
+ about width. On 7.23.6 / 7.24.3 / 7.25beta4 a settings menu with exactly one
108
+ property prints its value **one character per line**: `/system/identity/print`
109
+ returns `name: C` / `H` / `R` where 7.24.2 returns `name: CHR`. Measured on
110
+ stock CHR, the wrap is identical over REST `POST /rest/execute`, over
111
+ `ssh user@host "<cmd>"`, and over the mac-telnet console — including the
112
+ mac-telnet reader that has already answered the size probe with 512 columns,
113
+ and the ssh client that negotiates nothing. The console login modifiers
114
+ (`+c512w200h`, `+ct`, `+e`) do not change it, and it is not proportional to
115
+ the value's length. Every multi-property `print` is unaffected on the same
116
+ build, as is `:put [/system/identity/get name]`.
117
+
118
+ Consequence for this command: `data.ret` carries the device's console text
119
+ **verbatim**, wrap included — centrs cannot tell a layout newline from
120
+ content, and inventing a de-wrapper would corrupt legitimate output. Callers
121
+ that need a value rather than a rendering should read it structurally
122
+ (`retrieve`, or `api`) or ask the device for the scalar
123
+ (`:put [<menu>/get <property>]`). The integration examples compare identities
124
+ with `print` column wrapping collapsed for this reason — see
125
+ `collapsePrintWrapping` in `test/integration/chr.ts`.
83
126
  - Attribute values pass through **verbatim** — centrs does not guess types or
84
127
  coerce them (RouterOS REST tolerates string values; the native API is
85
128
  all-strings). Unlike `jo`-style KV builders, there is no per-value
@@ -187,8 +230,8 @@ many tokens):
187
230
  it fans out; mixing it with positionals or CDB selectors is
188
231
  `usage/conflicting-flags` (constitution: resolution providers).
189
232
 
190
- Because `execute` is the primary **write** path, a write-shaped fan-out
191
- (add/set/remove) is gated by **`--yes`**, confirmed **once** up front; without it
233
+ Because `execute` is the primary **write** path, a write-shaped fan-out is gated
234
+ by **`--yes`**, confirmed **once** up front; without it
192
235
  the error names the blast radius (how many routers). Write-ness is determined from
193
236
  the command alone (`canonicalizeExecuteCommand` + `isWriteShaped`), so the gate
194
237
  fires before any target is dialed. Per-target validation still runs because
@@ -73,7 +73,7 @@ arbitrary script expressions. The `$a` variable must survive the validation
73
73
  wrapper: if that outer string expands it before `:parse`, this example fails even
74
74
  though RouterOS accepts the original command.
75
75
 
76
- ### 5. Syntax reject from `:put [:parse ...]`
76
+ ### 5. Syntax reject before the write round-trip
77
77
 
78
78
  Malformed CLI is rejected by the syntax gate before semantic validation or the
79
79
  write round-trip.
@@ -82,9 +82,16 @@ write round-trip.
82
82
  centrs execute $R '/ip/address/add address="unterminated interface=ether1' --via rest-api --username $U --password $P --yes
83
83
  ```
84
84
 
85
- Envelope: `ok: false`, `error.code=validation/syntax`, `error.cause` preserves
86
- the RouterOS parse error string from `:put [:parse ...]`, `meta.via=rest-api`,
87
- and no address is added.
85
+ Envelope: `ok: false`, `error.code=validation/syntax`, `meta.via=rest-api`, and
86
+ no address is added.
87
+
88
+ **Which stage rejects it changed in GH#354.** This input is now caught by the
89
+ *offline* stage, so `error.context.validationStage="offline"`,
90
+ `error.cause="explain/canonicalizer/unterminated-string"`,
91
+ `error.context.span={"start":24,"end":54}`, and the device is never contacted —
92
+ see V1–V3 below. It reached `:put [:parse ...]` before that. The code, the
93
+ `meta.via`, and the no-mutation guarantee are unchanged, which is why the
94
+ assertion in `test/integration/execute.test.ts` still reads `validation/syntax`.
88
95
 
89
96
  ### 6. Semantic reject that `:parse` accepts
90
97
 
@@ -202,14 +209,16 @@ centrs execute $A "/ip/address/remove numbers=$ID" --via native-api --port $API_
202
209
  Envelope: `ok: true`, `meta.via=native-api`. A subsequent retrieve of
203
210
  `/ip/address` does not contain `$ID`.
204
211
 
205
- ### 15. Native syntax reject from `:parse`
212
+ ### 15. Native syntax reject
206
213
 
207
214
  ```bash
208
215
  centrs execute $A '/ip/address/add address="unterminated interface=ether1' --via native-api --port $API_PORT --username $U --password $P --yes
209
216
  ```
210
217
 
211
- Envelope: `ok: false`, `error.code=validation/syntax`, `error.cause` preserves
212
- the RouterOS parse error string, `meta.via=native-api`, and no address is added.
218
+ Envelope: `ok: false`, `error.code=validation/syntax`, `meta.via=native-api`, and
219
+ no address is added. The native twin of example 5, and it moved the same way:
220
+ the offline stage rejects this input now, so the verdict no longer depends on
221
+ the transport at all.
213
222
 
214
223
  ### 16. Native semantic reject after clean parse
215
224
 
@@ -285,6 +294,10 @@ Envelope: `ok: true`, `meta.via=mac-telnet`, `data.ret` is the console output an
285
294
  contains the device identity (cross-checked against REST). `meta.validation.source`
286
295
  is `:put [:parse ...] over mac-telnet`.
287
296
 
297
+ RouterOS 7.23.6/7.24.3/7.25beta4 print a one-property menu's value one
298
+ character per line (GH#352), so the comparison collapses `print` column
299
+ wrapping on both sides rather than asserting the device's layout.
300
+
288
301
  ### 21. Write (add) over mac-telnet
289
302
 
290
303
  ```bash
@@ -329,6 +342,10 @@ Envelope: `ok: true`, `meta.via=ssh`, `data.ret` is the cleaned console output a
329
342
  contains the device identity (cross-checked against REST). `meta.validation.source`
330
343
  is `:put [:parse ...] over ssh`.
331
344
 
345
+ RouterOS 7.23.6/7.24.3/7.25beta4 print a one-property menu's value one
346
+ character per line (GH#352), so the comparison collapses `print` column
347
+ wrapping on both sides rather than asserting the device's layout.
348
+
332
349
  ### S2. Multi-line read returns cleaned, column-aligned output
333
350
 
334
351
  ```bash
@@ -356,6 +373,150 @@ centrs execute 127.0.0.1 '/ip/address/add address=198.51.100.51/32 interface=eth
356
373
  Envelope: `ok: false`, `error.code=validation/unknown-attribute`, `meta.via=ssh`,
357
374
  and no address is added — the console `:parse` gate catches `bogus` pre-mutation.
358
375
 
376
+ ## Offline gate (stage 1)
377
+
378
+ Validation runs offline before it runs on the device
379
+ ([`docs/CONSTITUTION.md` → Validation is the product](../../docs/CONSTITUTION.md#validation-is-the-product),
380
+ GH#354). These two prove the stage exists and that a stage-1 rejection reaches no
381
+ router. Run by `test/integration/execute.test.ts`.
382
+
383
+ ### V1. Offline reject, with the device stage never asked
384
+
385
+ `:put [` is an unclosed bracket. The old local quote preflight counted quotes
386
+ only, so this used to cost a full round trip to be told no; the analyzer rejects
387
+ it with a byte span before anything is dialed.
388
+
389
+ ```bash
390
+ centrs execute $R ':put [' --via rest-api --username $U --password $P --json
391
+ ```
392
+
393
+ Envelope: `ok: false`, `error.code=validation/syntax`,
394
+ `error.context.validationStage="offline"`,
395
+ `error.context.span={"start":5,"end":6}`,
396
+ `error.context.diagnostics[0].code="explain/canonicalizer/unclosed"`,
397
+ `error.position` absent (the offline byte offset is never dressed up as a
398
+ RouterOS-reported one), `meta.validation.source` is the offline analyzer, and
399
+ `meta.validation.stages` is `offline: failed` followed by `device: skipped`.
400
+
401
+ ### V2. The same rejection with no reachable router at all
402
+
403
+ The proof that stage 1 opens no connection: point the same command at a port
404
+ nothing is listening on. A gate that dialed first would report a
405
+ `transport/*` error or hang until the timeout.
406
+
407
+ ```bash
408
+ centrs execute 127.0.0.1:1 ':put [' --via rest-api --username $U --password $P --json
409
+ ```
410
+
411
+ Envelope: identical to V1 — `ok: false`, `error.code=validation/syntax`,
412
+ `error.context.validationStage="offline"` — and **not** a `transport/*` code.
413
+
414
+ ### V3. `--validate=false` disables the offline stage too
415
+
416
+ One flag, one meaning. The escape hatch has to reach stage 1, or a caller hitting
417
+ an analyzer defect would have no way past it. RouterOS then rejects the command
418
+ itself on the run.
419
+
420
+ ```bash
421
+ centrs execute $R ':put [' --via rest-api --username $U --password $P --json --validate=false
422
+ ```
423
+
424
+ Envelope: `ok: false`, `error.code=routeros/request-failed` with RouterOS's own
425
+ `syntax error (line 1 column 7)` in the summary — the **device** rejecting it, not
426
+ centrs — plus `meta.validation.enabled=false`, `meta.validation.result="skipped"`,
427
+ and `meta.validation.stages` listing **both** stages as `skipped`. A gated
428
+ surface always reports every stage, so a consumer reading `stages` never has to
429
+ special-case the disabled shape.
430
+
431
+ ### V4. A device-only `missing …` rejection is still a rejection
432
+
433
+ The offline stage passes `/ip/address/find where=`: an empty `where` is not a
434
+ structural fault it can see. RouterOS's `:parse` returns
435
+ `missing value for where (line 1 column 24)` (CHR 7.24.4), a bare diagnostic
436
+ like `syntax error`, so the device stage must reject it (GH#375). Before that
437
+ fix the classifier only knew `syntax error`, `bad command name` and
438
+ `expected …`, so it reported this reply as a pass and the command ran.
439
+
440
+ ```bash
441
+ centrs execute $R '/ip/address/find where=' --via rest-api --username $U --password $P --json
442
+ ```
443
+
444
+ Envelope: `ok: false`, `error.code=validation/syntax`, `error.context.detail`
445
+ is RouterOS's own `missing value for where (line 1 column 24)`,
446
+ `error.position={"line":1,"column":24}`, and `meta.validation.stages` is
447
+ `offline: passed` followed by `device: failed`.
448
+
449
+ CHR 7.24.4 controls, via `:put [:parse "…"]`: `find where` without `=` parses
450
+ (IL). `print where=` is rejected differently (`expected end of command`, at
451
+ `where`). An unclosed bare `{` block or menu block (`/ip address {` then
452
+ newlines) returns `missing closing brace (line 3 column 1)`, while an unclosed
453
+ `do={` returns `expected closing brace`. Offline analysis usually catches the
454
+ unclosed-brace cases first.
455
+
456
+ ## Write authorization and returned runtime faults
457
+
458
+ These regressions are transport-independent and run in
459
+ `test/integration/execute.test.ts` (with MCP's selector form also covered by
460
+ `test/integration/mcp.test.ts`).
461
+
462
+ ### W1. Script-mode selector and positional writes still require confirmation
463
+
464
+ ```bash
465
+ centrs execute $R '/ip/address/remove [find comment=centrs-write-gate]' --via rest-api --username $U --password $P </dev/null
466
+ ```
467
+
468
+ The same contract holds for `/ip/address/remove *1`. Both stay script-mode so
469
+ RouterOS receives the raw selector/id syntax, but the independent safety gate
470
+ returns `usage/confirmation-required` before validation or mutation.
471
+
472
+ ### W2. Non-CRUD mutation verbs still require confirmation
473
+
474
+ ```bash
475
+ centrs execute $R '/ip/address/disable numbers=*1' --via rest-api --username $U --password $P </dev/null
476
+ ```
477
+
478
+ Envelope: `ok: false`, `error.code=usage/confirmation-required`; the address
479
+ remains enabled. Unknown RouterOS menu verbs also fail closed as write-shaped.
480
+ Root mutators such as `/import` and indirect execution through `:execute` use
481
+ the same gate.
482
+
483
+ ### W3. Space-separated menu reads run without confirmation
484
+
485
+ ```bash
486
+ centrs execute $R '/system identity print' --via rest-api --username $U --password $P </dev/null
487
+ ```
488
+
489
+ Envelope: `ok: true`. The space-separated spelling puts menu segments and the
490
+ verb in bare words, so the gate walks the known menu prefix and reads the first
491
+ word past it as the verb. `/ip address print where …` and
492
+ `:put [/system identity get name]` are reads the same way. The walk is also what
493
+ keeps an unlearned command gated: `/user expire-password print` names
494
+ `expire-password`, a published command outside both verb allowlists, whose
495
+ argument happens to be `print`, so it returns `usage/confirmation-required`.
496
+ Every command head in a statement is checked, including `[…]` and `do={…}`
497
+ bodies.
498
+
499
+ ### R1. A post-validation `/execute` rejection fails the overall envelope
500
+
501
+ ```bash
502
+ centrs execute $R '/ip/service/set www-ssl certificate=nope' --via rest-api --username $U --password $P --yes --json
503
+ ```
504
+
505
+ Envelope: `ok: false`, `error.code=routeros/invalid-value`, exit `1`, while
506
+ `meta.validation.result="passed"` and both stages remain `passed`: the rejection
507
+ happened at run time, after the scoped checks completed. The same result is
508
+ asserted over native API.
509
+
510
+ ### R2. Error-like prose remains ordinary script output
511
+
512
+ ```bash
513
+ centrs execute $R ':put "status: no such item appears in ordinary output"' --via rest-api --username $U --password $P --json
514
+ ```
515
+
516
+ Envelope: `ok: true`. Returned-text classification uses anchored grounded
517
+ shapes; it does not fail arbitrary output merely because it contains an error
518
+ phrase.
519
+
359
520
  ## Target selection (fan-out)
360
521
 
361
522
  These exercise the shared target-selection grammar