@tikoci/centrs 0.1.6 → 0.1.8-next.0

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 (89) hide show
  1. package/CHANGELOG.md +342 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +10 -6
  4. package/SECURITY.md +1 -1
  5. package/commands/AGENTS.md +2 -1
  6. package/commands/api/AGENTS.md +55 -30
  7. package/commands/api/README.md +135 -42
  8. package/commands/api/examples.md +275 -19
  9. package/commands/devices/README.md +21 -4
  10. package/commands/devices/examples.md +33 -2
  11. package/commands/execute/README.md +60 -18
  12. package/commands/execute/examples.md +224 -0
  13. package/commands/explain/README.md +42 -5
  14. package/commands/mcp/README.md +2 -1
  15. package/commands/retrieve/README.md +348 -4
  16. package/commands/retrieve/examples.md +442 -5
  17. package/commands/terminal/README.md +14 -4
  18. package/commands/terminal/examples.md +22 -3
  19. package/commands/transfer/README.md +55 -9
  20. package/commands/transfer/examples.md +106 -7
  21. package/docs/CLI.md +29 -21
  22. package/docs/CONSTITUTION.md +41 -16
  23. package/docs/MATRIX.md +38 -1
  24. package/docs/errors/README.md +3 -1
  25. package/docs/errors/cdb/create-failed.md +20 -0
  26. package/docs/errors/cdb/file-exists.md +23 -0
  27. package/docs/errors/input/invalid-query.md +36 -0
  28. package/docs/errors/input/invalid-target.md +25 -3
  29. package/docs/errors/input/local-destination.md +20 -0
  30. package/docs/errors/input/unsupported-query.md +19 -0
  31. package/docs/errors/internal/byte-string.md +13 -0
  32. package/docs/errors/routeros/invalid-value.md +3 -2
  33. package/docs/errors/settings/invalid-timeout.md +6 -0
  34. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  35. package/docs/errors/transport/stream-overflow.md +21 -0
  36. package/docs/errors/usage/removed-flag.md +20 -0
  37. package/docs/errors/validation/menu-unavailable.md +22 -0
  38. package/docs/errors/validation/not-followable.md +23 -0
  39. package/docs/errors/validation/package-missing.md +29 -0
  40. package/docs/errors/validation/unknown-path.md +16 -4
  41. package/docs/errors/wait/deadline-exceeded.md +14 -0
  42. package/docs/errors/wait/interrupted.md +10 -0
  43. package/package.json +14 -11
  44. package/src/absent-menu.ts +280 -0
  45. package/src/api-fanout.ts +22 -12
  46. package/src/api.ts +603 -194
  47. package/src/btest.ts +19 -17
  48. package/src/cli/api.ts +126 -34
  49. package/src/cli/devices.ts +16 -3
  50. package/src/cli/execute.ts +18 -5
  51. package/src/cli/retrieve.ts +216 -22
  52. package/src/cli/terminal.ts +10 -5
  53. package/src/cli/transfer.ts +23 -8
  54. package/src/core/envelope.ts +14 -0
  55. package/src/core/error-catalog.ts +70 -1
  56. package/src/core/execute-command.ts +313 -6
  57. package/src/core/inspect.ts +65 -27
  58. package/src/core/query.ts +508 -0
  59. package/src/core/routeros-errors.ts +67 -1
  60. package/src/data/winbox-cdb-write.ts +22 -8
  61. package/src/devices.ts +148 -37
  62. package/src/discover.ts +7 -8
  63. package/src/errors.ts +116 -7
  64. package/src/execute-fanout.ts +14 -9
  65. package/src/execute.ts +128 -116
  66. package/src/explain/catalog.ts +64 -16
  67. package/src/explain/menus.ts +27 -6
  68. package/src/explain.ts +6 -6
  69. package/src/index.ts +31 -1
  70. package/src/mcp/tools.ts +10 -15
  71. package/src/protocols/adapter.ts +235 -118
  72. package/src/protocols/btest-session.ts +3 -2
  73. package/src/protocols/endpoint.ts +10 -0
  74. package/src/protocols/mac-telnet-console.ts +12 -7
  75. package/src/protocols/native-api.ts +252 -73
  76. package/src/protocols/sftp.ts +44 -14
  77. package/src/protocols/ssh.ts +42 -14
  78. package/src/resolver/cdb.ts +28 -7
  79. package/src/resolver/index.ts +1 -0
  80. package/src/resolver/target.ts +84 -18
  81. package/src/retrieve-fanout.ts +51 -12
  82. package/src/retrieve-follow.ts +862 -0
  83. package/src/retrieve-sample.ts +244 -0
  84. package/src/retrieve-stream.ts +115 -0
  85. package/src/retrieve-wait.ts +286 -0
  86. package/src/retrieve.ts +741 -109
  87. package/src/settings.ts +15 -15
  88. package/src/transfer-fanout.ts +40 -15
  89. package/src/transfer.ts +234 -32
@@ -65,7 +65,15 @@ inert. (Earlier drafts said "the comment is not a lookup key" — superseded.)
65
65
  When `<router>` matches an entry (by `target` or a lookup key), credentials and
66
66
  other CDB-resident fields load from that entry. If nothing matches, the call
67
67
  still proceeds but `--username` / `--password` (or `CENTRS_USERNAME` /
68
- `CENTRS_PASSWORD`, or a `__default__` record) become mandatory.
68
+ `CENTRS_PASSWORD`, or a `__default__` record) become mandatory. The one
69
+ exception is a **pinned** CDB (`--cdb-file`, `CENTRS_CDB_FILE`,
70
+ `CENTRS_CDB_PASSWORD`, or a `centrs.env` CDB path) with no `__default__`
71
+ record: a DNS name or identity that matches nothing fails closed with
72
+ `cdb/not-found-target`, because it can't be told apart from a mistyped record.
73
+ An address that matches nothing still proceeds as a literal host: an IPv4 or
74
+ IPv6 literal (optionally with a port, IPv6 as `[…]:port`), an `http(s)://` URL,
75
+ or a MAC. On MCP every target must still be registered
76
+ (`cdb/target-not-registered`).
69
77
 
70
78
  ### Ambiguity
71
79
 
@@ -326,12 +334,13 @@ runs produce stable diffs.
326
334
  - A multi-target **write** is gated by `--yes`, confirmed once up front (not per
327
335
  target); without it the error/tip names the blast radius (how many routers).
328
336
  `--force` stays scoped to destructive `devices` CDB mutations.
329
- - Commands that are not safe to fan out (`terminal`, and `api --stream` /
330
- `--listen`) reject a multi-target selection with `usage/fanout-not-supported`.
337
+ - Commands that are not safe to fan out (`terminal`, and `api --stream` or a
338
+ `/listen` endpoint) reject a multi-target selection with `usage/fanout-not-supported`.
331
339
 
332
340
  ## Subcommands
333
341
 
334
342
  ```text
343
+ centrs devices init # create an empty CDB at the resolved path
335
344
  centrs devices list [--group G] [--where attr=value]
336
345
  [--near lat,lon,radius] [--bbox south,west,north,east]
337
346
  [--format json|yaml|text]
@@ -361,8 +370,16 @@ clack-style prompt used by `@tikoci/quickchr`); it is **not** a separate
361
370
  field-editing verb, and invoking it today returns `usage/not-implemented`. There
362
371
  is no `update`.
363
372
 
373
+ - `init` creates an empty open CDB at the resolved path (`--cdb-file`,
374
+ `CENTRS_CDB_FILE`, or the default), with parent directories and mode `0600`.
375
+ It never overwrites: on an existing, readable CDB it succeeds with a
376
+ `cdb/file-exists` warning and writes nothing. An explicit path is created only
377
+ by `init`; every other subcommand reports `cdb/not-found` for it (typo
378
+ protection), and that error names `init`.
364
379
  - `list` (alias `print`) shows resolved targets, their record type, group, and
365
- a one-line provenance summary. No network IO. `--where attr=value`
380
+ a one-line provenance summary. Each row carries the `identity=`/`mac=`/`ip=`
381
+ lookup keys it resolves by (when set), so one call maps names to records. No
382
+ network IO. `--where attr=value`
366
383
  (repeatable, AND-combined) filters by CDB-stored facts/comment-kv plus core
367
384
  record fields (`target`/`group`/`identity`/`mac`) — e.g. `--where
368
385
  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,12 +1,12 @@
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
8
8
  `docs/MATRIX.md` and `commands/execute/examples.md`, examples 1–11 over REST,
9
- 12–19 over the native API, 20–22 over mac-telnet, and S1–S4 over ssh, green via
9
+ 12–19 over the native API, 20–22 over mac-telnet, and S1–S5 over ssh, green via
10
10
  `bun run test:integration`), including multi-target fan-out (see **Target
11
11
  selection**, examples F1–F5). `romon` and `winbox-terminal` remain
12
12
  `not-started`. SNMP is retrieve-only and rejects `execute`.
@@ -15,7 +15,8 @@ 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.
18
+ the standard envelope. This includes selector-, positional-id-, and
19
+ non-CRUD-shaped writes.
19
20
  - Validation runs **offline first, then on the device** (see constitution:
20
21
  validation; GH#354). Stage 1 is the corpus-gated analyzer over the exact
21
22
  command string, with no connection: a syntax fault is rejected with a byte
@@ -27,8 +28,10 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
27
28
  GH#230): a single `:put [:parse "<cmd>"]` covers both the syntax and the
28
29
  unknown-attribute (name-level) gate on **every** transport — `:parse` never
29
30
  throws; its return value's printed form (`(evl …)` / `(evl bad parameter …)` /
30
- `(<%% bad command name …)`) is read from the REST/native `ret` *and* the
31
- console output. That check is necessary, not sufficient: value-type errors pass
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
32
35
  it, and the `/console/inspect request=child` / server re-validation semantic
33
36
  checks are separate. Command-level `request=completion` is deliberately not
34
37
  used: it hangs REST and closes native API on RouterOS 7.12.2 (GH#343), while
@@ -36,9 +39,21 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
36
39
  The outer RouterOS string literal escapes backslash, quote, and dollar so
37
40
  validation parses the caller's exact command rather than expanding variables
38
41
  in the wrapper.
39
- - centrs owns the **script-vs-structured gate** (`canonicalizeExecuteCommand` +
40
- `isWriteShaped` in `src/execute.ts`): it decides which validation runs and
41
- 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`
42
57
  canonicalizer is for canonicalization only — never the structured predicate
43
58
  (widening `structured` is a product regression). Pinned by
44
59
  `test/unit/execute-canonicalize-contract.test.ts`; the parser-vendoring
@@ -68,12 +83,11 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
68
83
  else passes its `detail`/body plus the HTTP status through
69
84
  `mapRouterOsError({ transport: "rest-api", httpStatus })`. There is no longer
70
85
  a hand-rolled `routeros/*` table on the REST side.
71
- - **Run-time fault mapping — later (JG-28).** `mapRouterOsError`'s table covers
72
- the faults seen so far; a known gap is errors that *pass* `:parse`/validation
73
- but fail at **run time** (a syntactically valid command the router rejects when
74
- executed). `tikoci/vscode-tikbook` has an error-string mapping for these worth
75
- porting into `src/core/routeros-errors.ts`. Background research, not yet
76
- 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.
77
91
  - Output is *string*-shaped for script-mode; richer parsing is a future
78
92
  concern. The envelope must still distinguish RouterOS errors from successful
79
93
  runs (a 200 with a RouterOS error string is still an error — see
@@ -122,14 +136,28 @@ selection**, examples F1–F5). `romon` and `winbox-terminal` remain
122
136
  mac-telnet. Callers that want IP-level execution may opt into ARP-based
123
137
  MAC → IP resolution with `--resolve arp` (plus `--via native-api`/`rest-api`)
124
138
  before protocol selection.
139
+ - An explicit `http(s)://host[:port]` target (positional, `--host`, or
140
+ `CENTRS_HOST`) names the REST service: with no `via` pinned anywhere,
141
+ auto-selection picks `rest-api` and keeps the URL's scheme and port
142
+ (`meta.settings.via` source `default` / `via:target-url`). Otherwise
143
+ auto-selection picks native-api. On the IP transports (native-api, REST,
144
+ ssh), a port typed into the target (`host:port`, `http://host:port`,
145
+ including an explicit `:80`/`:443`) is honored. The port settings ladder
146
+ (`--port`, `CENTRS_PORT`, a CDB `port=` comment, config) wins over it, and
147
+ the transport default comes last. The port carried by a matched CDB
148
+ record's `target` is not applied to native-api. mac-telnet addresses the MAC,
149
+ so its `--host`/`--port` are only the UDP delivery endpoint.
125
150
  - When the resolved transport is mac-telnet and **no** username or password is
126
151
  resolved from any source, execute emits a `tip/mac-telnet-no-credentials`
127
152
  pointer: mac-telnet logs in with RouterOS credentials over **MTWEI** (MD5 is
128
153
  refused by current RouterOS), so an empty login is rejected at connect time.
129
154
  It is a tip, not an error — the call is still attempted.
130
155
  - Default output is human-readable `text`; pass `--json`/`--format json`
131
- (or set `CENTRS_FORMAT=json`) for the structured envelope. Errors always
132
- render as `[code] summary` + `Fix:` lines in text mode.
156
+ (or set `CENTRS_FORMAT=json`) for the structured envelope. In text mode every
157
+ command renders errors the same way (`formatCentrsErrorText`): `[code] summary`,
158
+ then `At:` (RouterOS position or offline byte span), `Device said:` (RouterOS's
159
+ own words), `Fix:` and `Details:` when present. `--verbose` adds the full
160
+ `context`.
133
161
  - CDB comment-kv metadata may provide per-target `via`, `port`, and `ssh-key`
134
162
  overrides; CLI/API arguments still win.
135
163
  - RoMON and WinBox Terminal are lower priority than mac-telnet until their
@@ -175,6 +203,10 @@ with the first SSH consumer, `transfer`/sftp; `execute / ssh` (this command) and
175
203
  `terminal / ssh` followed as separate transports over the same host-`ssh`
176
204
  plumbing (`src/protocols/ssh.ts`) — all three SSH cells are `CHR-passed`.
177
205
 
206
+ A selected key adds `IdentitiesOnly=yes`, preventing fallback to unrelated
207
+ agent keys. Explicit OpenSSH `IdentityFile` configuration still applies; see
208
+ [`terminal` → SSH key selection](../terminal/README.md#ssh-key-selection).
209
+
178
210
  Over SSH — like mac-telnet — `execute` is a **console transport**: it runs a
179
211
  CLI line and returns text (`SshExecClient`, one `ssh user@host "<command>"` per
180
212
  command; validation reuses the same `:put [:parse …]` gate), so structured
@@ -215,9 +247,19 @@ many tokens):
215
247
  the command; no `--` needed). One `--quickchr` stays single-target; repeating
216
248
  it fans out; mixing it with positionals or CDB selectors is
217
249
  `usage/conflicting-flags` (constitution: resolution providers).
250
+ - `execute --host http://192.0.2.10:8080 '/system/resource/print'` (or
251
+ `execute --host <host|url> -- <command>`) — the `--host` **flag** stands in
252
+ for a missing `<target>`: a lone positional is the command, and nothing
253
+ before `--` is fine. There is no `<target>`, so there is no CDB lookup and no
254
+ record credential can be picked up; auth comes from flags, env or config.
255
+ Two or more positionals without `--` keep the legacy split, so
256
+ `execute <mac> <command> --via mac-telnet --host <udp-host>` (the mac-telnet
257
+ delivery override) is unchanged. `CENTRS_HOST` never changes positional
258
+ parsing: `execute '<command>'` with it set still reads the lone positional as
259
+ the target.
218
260
 
219
- Because `execute` is the primary **write** path, a write-shaped fan-out
220
- (add/set/remove) is gated by **`--yes`**, confirmed **once** up front; without it
261
+ Because `execute` is the primary **write** path, a write-shaped fan-out is gated
262
+ by **`--yes`**, confirmed **once** up front; without it
221
263
  the error names the blast radius (how many routers). Write-ness is determined from
222
264
  the command alone (`canonicalizeExecuteCommand` + `isWriteShaped`), so the gate
223
265
  fires before any target is dialed. Per-target validation still runs because
@@ -373,6 +373,23 @@ centrs execute 127.0.0.1 '/ip/address/add address=198.51.100.51/32 interface=eth
373
373
  Envelope: `ok: false`, `error.code=validation/unknown-attribute`, `meta.via=ssh`,
374
374
  and no address is added — the console `:parse` gate catches `bogus` pre-mutation.
375
375
 
376
+ ### S5. An explicit rejected key cannot fall back to an unrelated agent key
377
+
378
+ `test/integration/ssh-identity.test.ts` imports `$KEY` on a disposable CHR and
379
+ loads it into a private SSH agent. `$WRONG_KEY` is a second, untrusted key.
380
+ The test uses an empty OpenSSH config so no other `IdentityFile` is selected.
381
+
382
+ ```bash
383
+ centrs execute 127.0.0.1 ':put [/system/identity/get name]' --via ssh --port $SSH_PORT --username $U --ssh-key $WRONG_KEY --insecure --json
384
+ centrs transfer 127.0.0.1 list --via sftp --port $SSH_PORT --username $U --ssh-key $WRONG_KEY --insecure --json
385
+ printf ':put ("centrs-identity:" . [/system/identity/get name])\n/quit\n' | centrs terminal 127.0.0.1 --via ssh --port $SSH_PORT --username $U --ssh-key $WRONG_KEY --insecure
386
+ ```
387
+
388
+ Execute and transfer fail with `auth/failed`; the terminal relay preserves
389
+ OpenSSH's nonzero exit and `Permission denied` diagnostic. The test disables
390
+ interactive prompts on its terminal relay. Controls using `$KEY`, or omitting
391
+ `--ssh-key` to use the private agent, succeed for all three commands.
392
+
376
393
  ## Offline gate (stage 1)
377
394
 
378
395
  Validation runs offline before it runs on the device
@@ -428,6 +445,155 @@ and `meta.validation.stages` listing **both** stages as `skipped`. A gated
428
445
  surface always reports every stage, so a consumer reading `stages` never has to
429
446
  special-case the disabled shape.
430
447
 
448
+ ### V4. A device-only `missing …` rejection is still a rejection
449
+
450
+ The offline stage passes `/ip/address/find where=`: an empty `where` is not a
451
+ structural fault it can see. RouterOS's `:parse` returns
452
+ `missing value for where (line 1 column 24)` (CHR 7.24.4), a bare diagnostic
453
+ like `syntax error`, so the device stage must reject it (GH#375). Before that
454
+ fix the classifier only knew `syntax error`, `bad command name` and
455
+ `expected …`, so it reported this reply as a pass and the command ran.
456
+
457
+ ```bash
458
+ centrs execute $R '/ip/address/find where=' --via rest-api --username $U --password $P --json
459
+ ```
460
+
461
+ Envelope: `ok: false`, `error.code=validation/syntax`, `error.context.detail`
462
+ is RouterOS's own `missing value for where (line 1 column 24)`,
463
+ `error.position={"line":1,"column":24}`, and `meta.validation.stages` is
464
+ `offline: passed` followed by `device: failed`. `meta.validation.syntax=false`,
465
+ and `semantic` is absent: `:parse` refused the command, so the name-level check
466
+ was never asked (GH#435).
467
+
468
+ CHR 7.24.4 controls, via `:put [:parse "…"]`: `find where` without `=` parses
469
+ (IL). `print where=` is rejected differently (`expected end of command`, at
470
+ `where`). An unclosed bare `{` block or menu block (`/ip address {` then
471
+ newlines) returns `missing closing brace (line 3 column 1)`, while an unclosed
472
+ `do={` returns `expected closing brace`. Offline analysis usually catches the
473
+ unclosed-brace cases first.
474
+
475
+ ### V5. A menu from a package that is not installed is not a syntax error
476
+
477
+ RouterOS's `:parse` reports a menu the device does not have as a syntax fault —
478
+ `syntax error (line 1 column 10)` for `/zerotier/print`, pointing at the `/`
479
+ after the absent segment, and `bad command name container (line 1 column 2)` for
480
+ the space spelling `/container print` (CHR 7.23.7). centrs locates the segment,
481
+ looks the path up in its path catalog, and names the package (GH#361).
482
+
483
+ ```bash
484
+ centrs execute $R '/zerotier/print' --via rest-api --username $U --password $P --json
485
+ centrs execute $R '/container print' --via rest-api --username $U --password $P --json
486
+ ```
487
+
488
+ Envelope: `ok: false`, `error.code=validation/package-missing`,
489
+ `error.context.path` is `/zerotier` (then `/container`),
490
+ `error.context.packages` names the published package, the remediation points at
491
+ `/system/package/print` and never at the syntax, and `error.position` and
492
+ `error.context.detail` keep RouterOS's own column and words. V5–V7 all report
493
+ `meta.validation.syntax=false`. `semantic` is absent, or `not-applicable` for
494
+ the script-shaped `/container print` (GH#435).
495
+
496
+ ### V6. A menu no RouterOS build has is an unknown path
497
+
498
+ ```bash
499
+ centrs execute $R '/ip/nosuchmenu/print' --via rest-api --username $U --password $P --json
500
+ ```
501
+
502
+ Envelope: `ok: false`, `error.code=validation/unknown-path`,
503
+ `error.context.path=/ip/nosuchmenu`, `error.context.segment=nosuchmenu`. The
504
+ path catalog covers every 7.10–7.25 build with all extra packages, so a path it
505
+ lacks is not a missing package.
506
+
507
+ ### V7. A published menu gated on more than a package
508
+
509
+ ```bash
510
+ centrs execute $R '/interface/w60g/print' --via rest-api --username $U --password $P --json
511
+ ```
512
+
513
+ Envelope: `ok: false`, `error.code=validation/menu-unavailable`,
514
+ `error.context.path=/interface/w60g`, and `error.context.gates` carries both
515
+ published gates — package `wireless-rep` and hardware capability `60ghz` —
516
+ because a 60 GHz menu can be absent with the package installed. Nested and later
517
+ statements locate the same way (`:put [/interface/w60g/get [find] disabled]`
518
+ points at column 22). A menu-scope block (`/ip { nosuchmenu print }`) or a menu
519
+ named on an earlier line abstains and stays `validation/syntax`: the offline
520
+ analyzer does not resolve that parent, and centrs does not guess it.
521
+
522
+ ### V8. Wrong credentials: the device stage is not reached (GH#435)
523
+
524
+ ```bash
525
+ centrs execute $R '/system/identity/print' --via rest-api --username centrs-wrong --password centrs-wrong --json
526
+ ```
527
+
528
+ Envelope: `ok: false`, `error.code=transport/auth-failed`. `meta.validation`
529
+ has no `result`, `syntax` or `semantic`. Its stages are `offline: passed`, then
530
+ `device: skipped` with `reason: "not reached: transport/auth-failed"`. RouterOS
531
+ answered 401 before `:parse` could give a verdict.
532
+
533
+ ## Write authorization and returned runtime faults
534
+
535
+ These regressions are transport-independent and run in
536
+ `test/integration/execute.test.ts` (with MCP's selector form also covered by
537
+ `test/integration/mcp.test.ts`).
538
+
539
+ ### W1. Script-mode selector and positional writes still require confirmation
540
+
541
+ ```bash
542
+ centrs execute $R '/ip/address/remove [find comment=centrs-write-gate]' --via rest-api --username $U --password $P </dev/null
543
+ ```
544
+
545
+ The same contract holds for `/ip/address/remove *1`. Both stay script-mode so
546
+ RouterOS receives the raw selector/id syntax, but the independent safety gate
547
+ returns `usage/confirmation-required` before validation or mutation.
548
+
549
+ ### W2. Non-CRUD mutation verbs still require confirmation
550
+
551
+ ```bash
552
+ centrs execute $R '/ip/address/disable numbers=*1' --via rest-api --username $U --password $P </dev/null
553
+ ```
554
+
555
+ Envelope: `ok: false`, `error.code=usage/confirmation-required`; the address
556
+ remains enabled. Unknown RouterOS menu verbs also fail closed as write-shaped.
557
+ Root mutators such as `/import` and indirect execution through `:execute` use
558
+ the same gate.
559
+
560
+ ### W3. Space-separated menu reads run without confirmation
561
+
562
+ ```bash
563
+ centrs execute $R '/system identity print' --via rest-api --username $U --password $P </dev/null
564
+ ```
565
+
566
+ Envelope: `ok: true`. The space-separated spelling puts menu segments and the
567
+ verb in bare words, so the gate walks the known menu prefix and reads the first
568
+ word past it as the verb. `/ip address print where …` and
569
+ `:put [/system identity get name]` are reads the same way. The walk is also what
570
+ keeps an unlearned command gated: `/user expire-password print` names
571
+ `expire-password`, a published command outside both verb allowlists, whose
572
+ argument happens to be `print`, so it returns `usage/confirmation-required`.
573
+ Every command head in a statement is checked, including `[…]` and `do={…}`
574
+ bodies.
575
+
576
+ ### R1. A post-validation `/execute` rejection fails the overall envelope
577
+
578
+ ```bash
579
+ centrs execute $R '/ip/service/set www-ssl certificate=nope' --via rest-api --username $U --password $P --yes --json
580
+ ```
581
+
582
+ Envelope: `ok: false`, `error.code=routeros/invalid-value`, exit `1`, while
583
+ `meta.validation.result="passed"` and both stages remain `passed`: the rejection
584
+ happened at run time, after the scoped checks completed. The same result is
585
+ asserted over native API.
586
+
587
+ ### R2. Error-like prose remains ordinary script output
588
+
589
+ ```bash
590
+ centrs execute $R ':put "status: no such item appears in ordinary output"' --via rest-api --username $U --password $P --json
591
+ ```
592
+
593
+ Envelope: `ok: true`. Returned-text classification uses anchored grounded
594
+ shapes; it does not fail arbitrary output merely because it contains an error
595
+ phrase.
596
+
431
597
  ## Target selection (fan-out)
432
598
 
433
599
  These exercise the shared target-selection grammar
@@ -495,6 +661,17 @@ command). With no `--cdb-file`, both are ad-hoc literals, labeled by
495
661
  `meta.target.input` with no `recordIndex`: `data.summary = { total: 2, ok: 1,
496
662
  failed: 1 }`, exit `2`.
497
663
 
664
+ ### F7. An address that misses a pinned CDB is a literal; a name is not (#427)
665
+
666
+ ```bash
667
+ centrs execute $A:$API_PORT no-such-router --cdb-file $CDB --username $U --password $P --json -- /system/resource/print
668
+ ```
669
+
670
+ Neither positional matches a record. The address is dialed as a literal host
671
+ (inner `ok: true`). The name fails closed with `cdb/not-found-target`, because
672
+ it can't be told apart from a mistyped record name. `data.summary = { total: 2,
673
+ ok: 1, failed: 1 }`, exit `2`.
674
+
498
675
  ## Protocol selection notes
499
676
 
500
677
  For an unresolved MAC target, execute auto-selection defaults to mac-telnet
@@ -510,6 +687,53 @@ Envelope: `meta.via=mac-telnet` when `$MAC` is not resolved from CDB and no
510
687
  they must opt into MAC-to-IP resolution (`--resolve arp` + `--via …`) before
511
688
  protocol selection.
512
689
 
690
+ ### P1. An `http(s)://` target selects rest-api on its own port (#426)
691
+
692
+ ```bash
693
+ centrs execute http://$A:$REST_PORT ':put [/system/identity/get name]' --username $U --password $P --json
694
+ ```
695
+
696
+ Envelope: `ok: true`, `meta.via=rest-api`, `meta.settings.via =
697
+ { kind: "default", key: "via:target-url" }`. No `--via` is pinned, and the URL's
698
+ scheme and port are what get dialed.
699
+
700
+ ### P2. A typed `host:port` keeps native-api on that port (#426)
701
+
702
+ ```bash
703
+ centrs execute $A:$API_PORT ':put [/system/identity/get name]' --username $U --password $P --json
704
+ ```
705
+
706
+ Envelope: `ok: true`, `meta.via=native-api`, dialing `$API_PORT` (no
707
+ `--port` flag), the same way ssh and REST already treat a typed port.
708
+
709
+ ### P3. A pinned CDB doesn't refuse an address it has no record for (#427)
710
+
711
+ ```bash
712
+ centrs execute $A:$API_PORT ':put [/system/identity/get name]' --cdb-file $CDB --username $U --password $P --json
713
+ centrs execute http://$A:$REST_PORT ':put [/system/identity/get name]' --cdb-file $CDB --username $U --password $P --json
714
+ centrs execute no-such-router ':put [/system/identity/get name]' --cdb-file $CDB --username $U --password $P --json
715
+ ```
716
+
717
+ `$CDB` has no record for any of the three. The first two are `ok: true` over
718
+ native-api and rest-api: an IP, `[IPv6]`, `http(s)://` URL or MAC that misses a
719
+ pinned CDB is a literal host, exactly as with no CDB. The third is `ok: false`,
720
+ `error.code = cdb/not-found-target`: a name or identity that misses still fails
721
+ closed.
722
+
723
+ ## `--host` as the target (#428)
724
+
725
+ ### H1. `--host` stands in for `<target>`; a lone positional is the command
726
+
727
+ ```bash
728
+ centrs execute --host http://$A:$REST_PORT ':put [/system/identity/get name]' --username $U --password $P --json
729
+ centrs execute --host $A:$API_PORT --username $U --password $P --json -- ':put [/system/identity/get name]'
730
+ ```
731
+
732
+ Both are `ok: true`, the first over rest-api (the URL names REST) and the second
733
+ over native-api on `$API_PORT`. `meta.target.source = { kind: "cli", key:
734
+ "host" }`, with no `recordIndex`: there is no `<target>`, so there is no CDB
735
+ lookup. `retrieve`, `api` and `transfer` have the same rule (their H1).
736
+
513
737
  ## quickchr targets (#134)
514
738
 
515
739
  `$NAME` is the machine name of a running quickchr-managed CHR. Host/port/auth
@@ -172,7 +172,7 @@ today's path/verb/args split and script-vs-structured gate:
172
172
  The *container* half of that pair is since decidable. `src/explain/menus.ts`
173
173
  (#207) bakes only the menu-vs-command node type — not a schema — and it is
174
174
  version-less because the pinned trees show no `dir`↔`cmd` flip across
175
- 7.10.2 → 7.25beta3 or across architectures. So `/ip/route` now resolves as
175
+ 7.10.2 → 7.25beta5 or across architectures. So `/ip/route` now resolves as
176
176
  `navigation` (#210). The floor is unmoved: the table is deliberately
177
177
  incomplete, absence from it decides nothing, and every rule above it still
178
178
  refuses rather than guesses.
@@ -199,10 +199,11 @@ today's path/verb/args split and script-vs-structured gate:
199
199
  Discovery is the union of MikroTik's two published inventories, not the
200
200
  sitemap alone. A *branching* menu is served as a trailing-slash category URL
201
201
  with no `.md` of its own, while that menu's own entry is published at
202
- `<dir>/<basename>.md` and listed only in `llms.txt` — so sitemap-only
203
- discovery dropped 256 of 1,070 pages, silently and field-heavily. Generation
204
- asserts that every category dir still contributes its leaf, so the next
205
- inventory reshape fails loudly instead of shrinking the table (#285,
202
+ `<dir>/<basename>.md` (since 2026-10-06, `<dir>.md`) and listed only in
203
+ `llms.txt` — so sitemap-only discovery dropped 256 of 1,070 pages, silently
204
+ and field-heavily. Generation asserts that every category dir still
205
+ contributes its own page under one of those two spellings, so the next
206
+ inventory reshape fails loudly instead of shrinking the table (#285, #417,
206
207
  tikoci/rosetta#137).
207
208
 
208
209
  **Source refresh (#335, accepted 2026-09-13).** The inspect pins retain the
@@ -221,6 +222,42 @@ today's path/verb/args split and script-vs-structured gate:
221
222
  coherent generated catalog has 1,120 paths: 959 `both`, 67 `inspect`, and 94
222
223
  `published`; it still reports zero navigation-vs-command contradictions.
223
224
 
225
+ **Source refresh (accepted 2026-09-27).** The two current pins advance to the
226
+ channels then live — stable 7.24.2 → 7.24.4 and development 7.25beta3 →
227
+ 7.25beta5 — both still architecture-paired, both history anchors untouched.
228
+ The refresh is a structural no-op: the inspect union holds at 617 menus and
229
+ the catalog at 1,120 paths, with no path added or removed and no kind change.
230
+ What moved is current-only publication: the CLI Reference grows from 1,053
231
+ pages / 1,060 entries to 1,055 / 1,062, which documents
232
+ `/interface/ethernet/pon` and `/ip/ssh/known-hosts` for the first time and so
233
+ moves both from `inspect` to `both` — 961 `both`, 65 `inspect`, 94
234
+ `published`. Zero navigation-vs-command contradictions, unchanged.
235
+
236
+ **Source refresh (accepted 2026-10-11, #417).** MikroTik changed the CLI
237
+ Reference layout on 2026-10-06: a branching menu's own entry moved from
238
+ `<dir>/<basename>.md` to `<dir>.md`, and pages dropped their MDX `import`
239
+ preamble, so the `# title` line is no longer fenced off by an import. Both
240
+ guards refused, correctly, and the catalog check could not report anything
241
+ for days. The parser now accepts either layout (a category dir needs one of
242
+ the two page spellings, and both at once is reported); losing the page
243
+ entirely still aborts. The inventories reconcile exactly: 846 sitemap pages
244
+ plus 253 category pages equal the 1,099 pages `llms.txt` lists. The inspect
245
+ pins add 7.26beta1 (x86 and arm64) because the manual documents the
246
+ development channel, and a path new in it with no matching tree is
247
+ published-only with no gate to explain it. Reviewed against the committed
248
+ catalog: 47 paths added, 1 removed, 3 gate strings changed, and no kind
249
+ change, no provenance loss and no navigation-vs-command contradiction. The
250
+ additions are the 32 `/cmr` paths, `/interface/wifi/dashboard`,
251
+ `/system/dashboard`, `/tool/apptraffic`, `/interface/ethernet/pon/monitor`,
252
+ `/interface/wireless/snooper/snoop` and
253
+ `/interface/ethernet/switch/rule/reset-counters-all`; the removal is the
254
+ published-only `/system/routerboard/settings/keep-frequency`, which its page
255
+ no longer lists. The CLI Reference moves from 1,055 pages / 1,062 entries to
256
+ 1,099 / 1,106; the inspect union from 617 to 636 menus; the catalog from
257
+ 1,120 to 1,166 paths: 1,005 `both`, 67 `inspect`, 94 `published`.
258
+ `/interface/wifi/dashboard` and `/system/dashboard` are `inspect` only: the
259
+ trees carry them, the manual publishes no menu entry for them.
260
+
224
261
  There are real unknowns in how far offline parsing can go (expression
225
262
  grammar, scope fidelity vs `:parse`, `[]`-nesting corner cases). Grounding
226
263
  those unknowns was **phase 0** (#185); its findings are folded in throughout
@@ -125,7 +125,8 @@ benchmarked RouterOS agent support. Load-bearing findings that shape this design
125
125
  - `centrs_retrieve` mirrors `retrieve`, including attribute projection,
126
126
  `listAttributes`, and `group` fanout.
127
127
  - `centrs_execute` mirrors `execute`. Read-shaped commands run subject only to
128
- CDB membership; write-shaped add/set/remove are gated by the CDB policy below.
128
+ CDB membership; every write-shaped command is gated by the CDB policy below,
129
+ including selector/positional script forms and non-CRUD mutation verbs.
129
130
  Its MCP `destructiveHint` is **conservative — always `true`**, even though the
130
131
  tool also serves read-shaped commands: a single execute tool cannot vary the
131
132
  static annotation by argument, so it advertises the worst case and lets the