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

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 (91) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/GLOSSARY.txt +23 -1
  3. package/README.md +30 -18
  4. package/commands/api/AGENTS.md +21 -3
  5. package/commands/api/README.md +18 -7
  6. package/commands/api/examples.md +1 -1
  7. package/commands/btest/README.md +9 -0
  8. package/commands/btest/examples.md +7 -0
  9. package/commands/devices/README.md +72 -3
  10. package/commands/devices/examples.md +56 -1
  11. package/commands/execute/README.md +37 -6
  12. package/commands/execute/examples.md +24 -5
  13. package/commands/explain/README.md +675 -64
  14. package/commands/explain/examples.md +199 -42
  15. package/commands/retrieve/README.md +35 -3
  16. package/commands/retrieve/examples.md +114 -1
  17. package/commands/transfer/README.md +33 -4
  18. package/commands/transfer/examples.md +46 -0
  19. package/docs/CLI.md +30 -8
  20. package/docs/CONSTITUTION.md +14 -9
  21. package/docs/MATRIX.md +37 -22
  22. package/docs/errors/cdb/encrypt-failed.md +10 -0
  23. package/docs/errors/cdb/password-required.md +5 -4
  24. package/docs/errors/cdb/plaintext-backups.md +11 -0
  25. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  26. package/docs/errors/input/invalid-routeros-path.md +25 -3
  27. package/docs/errors/routeros/permission-denied.md +24 -0
  28. package/docs/errors/transport/edge-auth-failed.md +22 -0
  29. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  30. package/docs/errors/usage/invalid-cursor.md +20 -0
  31. package/docs/errors/validation/unknown-path.md +9 -0
  32. package/package.json +9 -5
  33. package/src/api-fanout.ts +2 -0
  34. package/src/api.ts +46 -39
  35. package/src/btest.ts +7 -0
  36. package/src/cli/api.ts +18 -1
  37. package/src/cli/btest.ts +9 -0
  38. package/src/cli/devices.ts +76 -8
  39. package/src/cli/execute.ts +9 -0
  40. package/src/cli/explain.ts +206 -47
  41. package/src/cli/retrieve.ts +9 -0
  42. package/src/cli/terminal.ts +9 -0
  43. package/src/cli/transfer.ts +10 -1
  44. package/src/core/envelope.ts +10 -3
  45. package/src/core/error-catalog.ts +28 -2
  46. package/src/core/execute-command.ts +53 -0
  47. package/src/core/fanout.ts +12 -2
  48. package/src/core/inspect.ts +4 -5
  49. package/src/core/menu-path.ts +89 -0
  50. package/src/core/routeros-errors.ts +25 -0
  51. package/src/devices.ts +154 -9
  52. package/src/execute-cmr.ts +2 -8
  53. package/src/execute-fanout.ts +2 -0
  54. package/src/execute.ts +55 -20
  55. package/src/explain/args.ts +115 -10
  56. package/src/explain/blocks.ts +2 -2
  57. package/src/explain/catalog.ts +22 -12
  58. package/src/explain/group-faults.ts +162 -29
  59. package/src/explain/operators.ts +2 -1
  60. package/src/explain/pasted-prompt.ts +198 -0
  61. package/src/explain/pathresolve.ts +2 -2
  62. package/src/explain/scope-brace.ts +226 -11
  63. package/src/explain/segment.ts +138 -3
  64. package/src/explain/symbols.ts +148 -8
  65. package/src/explain/values.ts +55 -4
  66. package/src/explain/verbsplit.ts +2 -2
  67. package/src/explain/write.ts +2 -2
  68. package/src/explain-live-facets.ts +739 -0
  69. package/src/explain-live-types.ts +490 -0
  70. package/src/explain-live.ts +593 -0
  71. package/src/explain.ts +397 -43
  72. package/src/index.ts +10 -0
  73. package/src/protocols/adapter.ts +90 -10
  74. package/src/protocols/btest-session.ts +10 -30
  75. package/src/protocols/ec-srp5.ts +19 -10
  76. package/src/protocols/mac-telnet-console.ts +2 -2
  77. package/src/protocols/mac-telnet.ts +38 -10
  78. package/src/protocols/native-api.ts +13 -12
  79. package/src/resolver/cdb.ts +58 -2
  80. package/src/resolver/index.ts +1 -0
  81. package/src/resolver/quickchr-provider.ts +5 -0
  82. package/src/resolver/selection.ts +11 -0
  83. package/src/retrieve-fanout.ts +2 -0
  84. package/src/retrieve-follow.ts +2 -1
  85. package/src/retrieve-sample.ts +2 -1
  86. package/src/retrieve-wait.ts +10 -6
  87. package/src/retrieve.ts +88 -27
  88. package/src/settings.ts +1 -0
  89. package/src/terminal.ts +7 -0
  90. package/src/transfer-fanout.ts +2 -0
  91. package/src/transfer.ts +117 -27
@@ -10,12 +10,11 @@ validation): the cheap, safe knowledge tier in front of the runners
10
10
  (`execute` / `api` / `retrieve`).
11
11
 
12
12
  Status: offline baseline `verified`; live probes over `rest-api` and `native-api`
13
- `designed`. **`centrs explain '<input>'` runs today**, without a router or CDB.
13
+ `CHR-passed`. **`centrs explain '<input>'` runs without a router or CDB**, and
14
+ `centrs explain <router> '<input>'` adds read-only device evidence.
14
15
  [`docs/MATRIX.md`](../../docs/MATRIX.md#offline-analysis) tracks these separately;
15
16
  [#90](https://github.com/tikoci/centrs/issues/90) indexes the remaining tasks.
16
17
  The spec is ratified on the [phase-0 grounding lab](#phase-0-ratification-185).
17
- The current finish line is [Offline baseline acceptance](#offline-baseline-acceptance),
18
- not completion of the live protocol cells.
19
18
  Load-bearing rules — envelope, errors, settings precedence, identity,
20
19
  validation, protocol selection — live in
21
20
  [`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md).
@@ -258,6 +257,22 @@ today's path/verb/args split and script-vs-structured gate:
258
257
  `/interface/wifi/dashboard` and `/system/dashboard` are `inspect` only: the
259
258
  trees carry them, the manual publishes no menu entry for them.
260
259
 
260
+ **Source refresh (accepted 2026-10-30).** The CLI Reference added
261
+ `compare-versions`, a version-comparison widget page with no `**Type:**`
262
+ entry. `llms.txt` lists it as a page and the sitemap serves it as a category
263
+ URL, so it aborted the parser and would have aborted the orphan-dir check.
264
+ It joins `index` as a hand-audited non-command slug, excluded from both
265
+ inventories. The CLI Reference moves from 1,099 pages / 1,106 entries to
266
+ 1,109 / 1,116; the inspect pins and the 636-menu union are unchanged. The
267
+ catalog grows from 1,166 to 1,176 paths: 1,005 `both`, 67 `inspect`, 104
268
+ `published`. It adds 10 paths and removes none, with no kind change and no
269
+ provenance loss. The additions are `/app/settings/remove-certificate`, three
270
+ `/system/resource/hardware` commands (`power-cycle`, `power-reset`,
271
+ `reset-overcurrent`), and `/interface/wifi/easymesh` with five children. The
272
+ three `/cmr/client` gate strings drop `!powerpc`. easymesh is published with
273
+ no gate and is in no CHR tree through 7.26beta3, so the unexplained residue
274
+ grows from `/root/terminal` alone to seven paths.
275
+
261
276
  There are real unknowns in how far offline parsing can go (expression
262
277
  grammar, scope fidelity vs `:parse`, `[]`-nesting corner cases). Grounding
263
278
  those unknowns was **phase 0** (#185); its findings are folded in throughout
@@ -373,9 +388,8 @@ native API's `/execute` **with `as-string=""`** returns the parser text —
373
388
  including the `(line N column M)` diagnostic on a syntax error — byte-identical
374
389
  to what a console transport prints. The opaque `*NN` value seen otherwise is the
375
390
  **no-`as-string` job handle**, not a `:parse` limitation; the readout rule is
376
- "pass `as-string` and interpret `ret`". The existing execute gate already passes
377
- `as-string` but currently discards `ret`; phase 2 must extract/fix that shared
378
- readout rather than copy the omission. `/console/inspect`
391
+ "pass `as-string` and interpret `ret`". The execute gate passes
392
+ `as-string` and classifies `ret`; live explain reuses that shared classifier. `/console/inspect`
379
393
  (highlight/syntax/completion) works over the native API's `talk` framing too. So
380
394
  the line/column diagnostic is available on both live cells — the earlier caveat
381
395
  that only a console transport surfaces the parser text is retired.
@@ -401,10 +415,8 @@ router therefore never reinterprets a previously valid offline invocation.
401
415
  the offline form with nothing else to analyze. A positional always wins, and
402
416
  when fd 0 is redirected anyway the result carries a
403
417
  [`usage/stdin-ignored`](../../docs/errors/usage/stdin-ignored.md) warning rather
404
- than quietly dropping the pipe. `--file -` names stdin explicitly; it is the
405
- offline fix today, since the live form it also spells
406
- (`… | centrs explain edge1 --file -`) is parsed and then refused with
407
- `usage/not-implemented` until phase 2. The narrower rule is
418
+ than quietly dropping the pipe. `--file -` names stdin explicitly in both forms,
419
+ including live `… | centrs explain edge1 --file -`. The narrower rule is
408
420
  deliberate and measured: `runCli` is called in-process by tests, so reading fd 0
409
421
  on any path a positional invocation reaches consumes the invoking shell's
410
422
  stdin — it fed those bytes to the analyzer and broke 21 unrelated tests once,
@@ -652,7 +664,7 @@ and its phase is named below.
652
664
  | `structure.statements[].command` | `path` + `verb`, plus **`args` where the argument list was read** (#202c-1); the ordered token list is `statements[].arguments.tokens` |
653
665
  | `structure.statements[].transport` | complete for commands: `api-candidate`, `execute`, or fail-closed `unknown`, with an equivalent `centrs` invocation and opt-in `curl` for tested REST mappings (#202c-2). The `basis` names the evidence for the route it chose or refused |
654
666
  | `symbols.occurrences[]` | Q13 name/span/class plus semantic `role`, result-local `bindingIds`, sigil spelling, and an abstention note where needed; **plus** (#239 S2) `valueId` on declarations/assignments with a literal RHS in the same statement, and `reachingValueIds` + `reachingUnknown` on references (flow-sensitive, branch → set, loop/non-literal → `unknown`, no RHS evaluation) |
655
- | `values.occurrences[]` | result-local value id/span plus three separate fact homes: offline `shapeHints`; live `observedType` and `schemaType` remain absent until phase 2. `kind: "element"` rows are array members, carrying `parent` and, for a keyed member, `name` |
667
+ | `values.occurrences[]` | result-local value id/span plus three separate fact homes: offline `shapeHints`; live `observedType` and `schemaType` are added only by a live call ([Live value types](#live-value-types-225)). `kind: "element"` rows are array members, carrying `parent` and, for a keyed member, `name` |
656
668
  | `spans` | comment runs and resolved variable classes only; **no value shape or type** |
657
669
  | `schema`, `completion` | absent — live evidence, phase 2 |
658
670
 
@@ -744,13 +756,10 @@ and its phase is named below.
744
756
  rejected by RouterOS at `{` and resolve no reference. In an expression the
745
757
  remaining `-dns` is scanned as subtraction plus its right operand; inside a
746
758
  string it is literal text.
747
- - **Evidence is offline-shaped, not the whole contract.** The bullet above says
748
- an evidence entry carries `source` (`canonicalizer` vs `live-inspect`) and a
749
- RouterOS version stamp; phase 1 emits neither the live source nor the stamp,
750
- because no probe ran and there is no device to stamp. `ExplainEvidence` is
751
- typed to what offline produces rather than pre-declaring variants nothing can
752
- emit, so phase 2 widens the union and adds the stamp — an addition, not a
753
- change to what a caller reads today.
759
+ - **Evidence follows its producer.** Offline calls emit only `canonicalizer`
760
+ entries, with no device version. Live calls add `live-inspect` entries with
761
+ probe outcomes and available device provenance; existing offline entries
762
+ retain their meaning. See [Live probe foundation](#live-probe-foundation-491).
754
763
  - **Value facts are three axes, never one `type` string (#225).** RouterOS
755
764
  types values at parse time while `highlight` classes every value byte `none`,
756
765
  so type evidence is not a subset of Q12 spans. Each result-local
@@ -763,7 +772,7 @@ and its phase is named below.
763
772
  The stable/testing CHR matrix fixes the offline lexicon: `num`, `ip`,
764
773
  `ip-prefix`, `ip6`, `ip6-prefix`, `id`, `time`, `array`, `mac`, `bool`, and
765
774
  `str` — `op` is deliberately not a member: a deferred `(> …)` abstains
766
- offline and is reported only as a live `observedType` (#288); quoted literals
775
+ offline and live alike (#288, #225); quoted literals
767
776
  hint only `str`, while malformed and out-of-range address-like controls
768
777
  abstain even in named attributes. Most vocabulary borrows RouterOS scripting
769
778
  type names (with the explicit `macAddr` schema exception below), so a hint
@@ -797,8 +806,9 @@ and its phase is named below.
797
806
  1 and identical `:parse` IL — only `:typeof` distinguishes them
798
807
  (`test/fixtures/explain/operators.json` → `sweep.runtime`
799
808
  `typeof-deferred`/`deferred-array-typeof` and `sweep.opAxis` `defer-*` on
800
- 7.21.5, 7.23.3, 7.24rc4). Offline abstains on every such form; `op` lives
801
- only as a live `observedType` (#288, a bounded #225 decision).
809
+ 7.21.5, 7.23.3, 7.24rc4). Offline abstains on every such form, and so does
810
+ the live `observedType`, which never sends `:typeof` (#288, #225). `op` has
811
+ no producer.
802
812
  Shape remains non-authoritative: `100000w` is time-shaped but observed as
803
813
  `str`. Argument context remains separate: on 7.23.3 and 7.24rc3, both
804
814
  `/ip/address address=2.2` and firewall `src-address=2.2` canonicalize it to
@@ -812,8 +822,8 @@ and its phase is named below.
812
822
  and does not widen the hint. Thus a hint never validates a value or becomes a
813
823
  diagnostic, and never changes `runtimeAcceptance`. Quoting likewise never
814
824
  earns a wrong-type diagnostic: commands may cast quoted strings at runtime.
815
- Offline emits only `shapeHints`; phase 2 adds the two live facts into their
816
- existing homes when its safe IL/schema producers land.
825
+ Offline emits only `shapeHints`; a live call adds the two live facts into
826
+ their existing homes ([Live value types](#live-value-types-225)).
817
827
 
818
828
  **A located array is read member by member (#225 V1).** Each member of a
819
829
  `{…}`/`(…)` literal is its own `values.occurrences[]` row with `kind:
@@ -1128,8 +1138,9 @@ navigated, so the context stays as unknown as it already was.
1128
1138
  `empty-group`, `misplaced-separator`, `missing-operand` and
1129
1139
  `invalid-at-sign` (#505, #511, #512, below), and the four token faults
1130
1140
  `glued-token`, `invalid-variable`, `bad-string-variable` and
1131
- `invalid-name` (#506, #518, below). Seventeen classes the device itself
1132
- rejects.
1141
+ `invalid-name` (#506, #518, below), and the two unquoted-byte faults
1142
+ `unquoted-reserved-byte` and `unquoted-non-ascii` (#358, #371, below).
1143
+ Nineteen classes the device itself rejects.
1133
1144
  - `warning` — `over-depth`, because it is centrs's own resource bound and says
1134
1145
  nothing about whether the input is legal; `possible-spaced-argument`
1135
1146
  (#350), a spelling RouterOS accepts and marks obsolete; `discarded-close`
@@ -1326,8 +1337,9 @@ query and brace roles. The `$` inside a string is checked by the escape walk
1326
1337
  (`src/explain/quoted-string.ts`), and the stray close by the segmenter
1327
1338
  (`src/explain/segment.ts` → `discardFrom`). Every rule is a `:parse` answer on
1328
1339
  CHR 7.24.5 and 7.23.8, identical on both, and
1329
- `test/fixtures/explain/token-faults.json` pins all 145 rows (80 rejections and
1330
- 65 acceptances, with the acceptances' IL). `test/integration/explain-token-faults.test.ts`
1340
+ `test/fixtures/explain/token-faults.json` pins these rows and the unquoted-byte
1341
+ rows below: 292 in all (184 rejections and 108 acceptances, with the
1342
+ acceptances' IL). `test/integration/explain-token-faults.test.ts`
1331
1343
  holds the device to that fixture, and `test/unit/explain-token-faults.test.ts`
1332
1344
  holds offline to it.
1333
1345
 
@@ -1379,11 +1391,59 @@ variable name, so the next `"` does not close the string. Every scanner in
1379
1391
  `explain` closes it there, so both `$` rules and the glued-token rule abstain
1380
1392
  around a string that ends in `$"` (2 of 948 corpus scripts use the form).
1381
1393
  `$$`, `$(…)`, `$[…]` and `${…}` outside a string, where the device's byte is
1382
- not the `$`. A `\` continuation inside a glued token (`"a"\⏎"b"`). The head
1383
- word of a statement (`$v"a"`, `{…}:put 2`). `:local a=b`, which 7.24.5 reads as
1394
+ not the `$`. A `\` continuation inside a glued token (`"a"\⏎"b"`). A glued
1395
+ head word (`$v"a"`, `{…}:put 2`). `:local a=b`, which 7.24.5 reads as
1384
1396
  a named argument and 7.23.8 as a syntax error. And a name in `/local` or
1385
1397
  `:local name=a_b`.
1386
1398
 
1399
+ ### Unquoted bytes (#358, #371)
1400
+
1401
+ A `?`, a backtick and a non-ASCII byte are never part of a bare word, and
1402
+ neither is a second `=` in a menu command's argument. The rules ride the
1403
+ token walk (`src/explain/group-faults.ts`). They were grounded with live
1404
+ explain (`explainLiveEnvelope`: `:parse` and `highlight`) on CHR 7.24.5 and
1405
+ 7.23.8, which answered identically. The 147 rows are in
1406
+ `test/fixtures/explain/token-faults.json`.
1407
+
1408
+ | Code | Device rule | Rejected | Accepted |
1409
+ | ---- | ----------- | -------- | -------- |
1410
+ | `unquoted-reserved-byte` | `?` and a backtick, at the byte, outside a string or comment. A second `=` in a menu command's argument, at that `=` | `name=a?b`, ``name=a`b``, `name=?b`, `:put a?b`, `[:len a?b]`, `where comment=a?b`, `[find comment=a?b]`, `where (comment=a?b)`, `where comment in {a?b}`, `{a?b}`, `(a?b)`, `/ip/address/print?`, `name=a=b`, `name==b`, `comment=x==`, `url=http://x/a?b=c`, `regexp=^a?b`, `public-key=…=`, `secret=abc==`, `on-event=a=b` | `name="a?b"`, ``name="a`b"``, `name="a=b"`, `where comment=a=b`, `[find comment=a=b]`, `[/ip/address/find comment=a=b]`, `where (comment=a=b)`, `where comment in {"a?b"}`, `{a=b}`, `{"a?"=1}`, `# a?b`, `("a"."?")` |
1411
+ | `unquoted-non-ascii` | A non-ASCII byte outside a string or comment, at its first byte; the span is the whole run | `:put café`, `$café`, `name=router-café`, `name=🛰`, `/systemé/identity`, `printé`, `namé=x`, `where comment=café`, `{café}`, `(café)`, `"x"é`, a non-breaking space, `“x”`, `address=…` | `:put "café"`, `:local "café" 1`, `$"café"`, `"a$café"` (`$caf` then `é`), `name="router-🚀"`, `# café`, `do={:put "café"}` |
1412
+
1413
+ **Where the device reports, offline reports.** In a menu command's argument
1414
+ name the device names the name's first byte (`/ip/address/add addré=1` and
1415
+ `a?b=1` at the `a`), so the span is the name. A directive (`:put café=1`) and
1416
+ a `$` call (`$f café=1`) report at the byte. A declared name keeps the
1417
+ existing `invalid-name` (`:local café`, `:foreach café`). A query, and a
1418
+ group or array inside one, keeps `=` as an operator but not these bytes
1419
+ (`where (comment=café)` and `in {café}` are rejected at the `é`), and the slash-joined `[/ip/address/find …]` is a query, as
1420
+ `[find …]` is. A leading BOM is a positional fact and is not read as part of
1421
+ the first word. The informational `non-ascii` fact is still published beside
1422
+ the error, so `isPositionalFact("non-ascii")` is unchanged.
1423
+
1424
+ **No property parser takes these bytes.** This is the difference from the
1425
+ glued-token rule. `regexp=a?b` (layer7 and DNS static), ``regexp=a`b``,
1426
+ `public-key=<base64>=`, `secret=abc==`, `password=abc=`,
1427
+ `http-header-field=a:b=c` and the script bodies `on-event=a=b` and
1428
+ `up-script=a=b` are all rejected at the byte.
1429
+
1430
+ **Measured.** Corpus `e12d5d4e58a4`: 8 scripts move to `fail` (7 from `warn`,
1431
+ one from `pass`), and 20 already-failing scripts gain a new code. All 28 are
1432
+ device rejections on every channel. 8 of their first new errors sit on the
1433
+ 7.24.5 diagnostic's byte, 20 come after it, and none precedes it. There are no
1434
+ false rejects. On the conformance cases, 10 missed rejections move to `fail`
1435
+ (the `non-ascii-outside-strings` u10–u12 and u14–u16, z90, z92, g18 and e33), with no new
1436
+ false reject.
1437
+
1438
+ **Not covered here.** `:put a=b`, `:local x a=b` and `[:len a=b]`: 7.24.5
1439
+ reads them as a named argument (`bad parameter a`, at the end), and 7.23.8
1440
+ rejects the `=`. A query value that starts with `=` (`where comment==b`,
1441
+ `expected comment value`). A bare `source=` value,
1442
+ which the device rejects at its first byte whatever it holds (`source=abc`), so
1443
+ a byte rule there reports after the device's byte. And `:set` of an undeclared
1444
+ name, which the device rejects at the name's first byte (`:set zzz 2`), so
1445
+ `:set café` is reported at the `é`, after it.
1446
+
1387
1447
  ### The token stream a rule reads (#316)
1388
1448
 
1389
1449
  `lexArguments` is all-or-nothing by design: its consumer renders a runnable REST
@@ -1599,15 +1659,34 @@ catalog-named command, with a `:` scripting directive, or with an absolute
1599
1659
  command — corpus script `forum/amm0/topic-184067-routeros-lsp-for-better-syntax-checking-command-completion-in-editors-like-vscod/post-0014-snippet-02.rsc` is the directive case,
1600
1660
  `ethernet { :put … }`.
1601
1661
 
1602
- That last rule is bounded by what a STATEMENT can start with, which is what
1603
- keeps `{1;2}` an array literal: an array member opens with a digit or a quote
1604
- and never reaches the identifier branch. It is also deliberately not widened
1605
- into a claim of existence. Menu-scope resolution is schema-dependent —
1606
- `/ip { zzznotamenu { print } }` is a `syntax error` on device — and offline has
1607
- no schema, so a catalog MISS abstains rather than being decided either way.
1608
- That is the catalog's own "a hit is decisive, a miss says nothing" contract, and
1609
- turning such a miss into an acceptance would be the mirror-image defect of the
1610
- one being fixed here.
1662
+ Issue #348 only stopped the failure. It still read `address { … }` as a command
1663
+ `/ip address` whose operand was the brace, so the body was never segmented.
1664
+ That rule was bounded by what a statement can start with, on the theory that
1665
+ `{1;2}` there is an array. The device says otherwise (#351, CHR 7.24.5 and
1666
+ 7.23.8): a brace right after a menu word is a statement block whatever the body
1667
+ holds. `/ip { address { 1;2 } }` is `bad command name 1` at the `1`, and
1668
+ `/ip { address { a;b } }` runs `a` as `add`. The old reading failed the OUTER
1669
+ brace at depth three, and passed `print {1;2}` and `print; zzz` inside the body.
1670
+
1671
+ So a relative run inside a menu block now opens a block of its own when the
1672
+ menu it names is known: the enclosing block's path, read outward from the
1673
+ source, plus the run, named by a shipped structure table
1674
+ (`scope-brace.ts` → `menuBlockPath`). The segmenter, the comment mask, the
1675
+ argument lexer and the group-fault walk all ask it. The body is then read like
1676
+ `/ip/address { … }`: `print {1;2}` there fails at its own `{`, the device's
1677
+ byte, and an unresolvable body statement abstains at every depth.
1678
+
1679
+ It is deliberately not widened into a claim of existence. Menu-scope
1680
+ resolution is schema-dependent — `/ip { zzznotamenu { print } }` is a
1681
+ `syntax error` on device — and offline has no schema, so a catalog MISS
1682
+ abstains rather than being decided either way. That is the catalog's own "a
1683
+ hit is decisive, a miss says nothing" contract. Three spellings keep the older
1684
+ reading because the source alone does not name the menu: a block after a
1685
+ navigation statement (`/ip⏎address { … }`), an abbreviation (`addr {`) and a
1686
+ digit-led menu (`6to4 {`, which the resolver does not read as a path). After a
1687
+ navigation, `/ip⏎address { 1;2 }` still fails at the outer brace and
1688
+ `/ip⏎address { print {1;2} }` still passes; the device answers
1689
+ `bad command name 1` and `expected end of command` at the inner brace.
1611
1690
 
1612
1691
  ### The `=` is optional in ordinary arguments too (#350)
1613
1692
 
@@ -1650,6 +1729,36 @@ device `syntax error` that offline does not report. That is a missed
1650
1729
  rejection for the device preflight to catch, never a false reject. On the
1651
1730
  pinned corpus the widening changed no census.
1652
1731
 
1732
+ ### First-segment script-body abbreviations warn instead of rejecting (#525)
1733
+
1734
+ RouterOS takes any unique prefix of an argument name, including the first
1735
+ segment: `down={ :beep }` lowers to `down-script=` on CHR 7.22.1–7.25, as do
1736
+ bodies opening with one bare command word (`down={ beep }`) or after a comment
1737
+ line. Every word is a prefix of `<word>-script`, so without the command's
1738
+ argument list (schema) offline cannot tell `down=` from `comment=`, which the
1739
+ device rejects. The contract: on a non-root command, outside `where`, an
1740
+ unproven `name={` with a script-like body warns
1741
+ `explain/canonicalizer/possible-script-body-abbreviation` (verdict `warn`, the
1742
+ offline gate lets it through) instead of failing with `invalid-command-brace` —
1743
+ glued or with the brace spaced off the `=` (`down= { :beep }` lowers to
1744
+ `down-script=` on CHR 7.25 exactly like the glued spelling, so the spaced
1745
+ owner warns under the same gates). The brace reads as a body for every
1746
+ reader — a `#` inside is a comment, not `invalid-hash` — while proven slots
1747
+ (`down-script=`, `s=`, `on-…`) stay silent and control scopes (`do={…}`) are
1748
+ never guesses. A bare word followed by `;`-separated members is array
1749
+ members, not commands, so `.proplist={name;comment}` keeps its error alongside
1750
+ the other array-content braces (`{1;2}`, `{"x"}`, `{}`), the positional form,
1751
+ and `where`. A root directive's slots (`:put x={…}`, glued or spaced) and a `where` named
1752
+ comparison (`where gateway={…}`, however wrapped) keep their error too: the
1753
+ slot table and the property parser decide there, so the abbreviation guess
1754
+ never shields them (#554). The integration twin pins both device directions.
1755
+
1756
+ The tradeoff is measured: a verdict sweep of the 948-row pinned corpus
1757
+ population against the pre-batch baseline reports zero verdict or diagnostic
1758
+ changes, so nothing in the corpus pays for the abstention. The one deliberate
1759
+ over-warn — `comment={ :beep }` warns though the device rejects it — has no
1760
+ corpus occurrence.
1761
+
1653
1762
  ### `'` is not a RouterOS token (#355)
1654
1763
 
1655
1764
  `'` (U+0027) is not a string delimiter and not a legal bare byte. Only `"` opens
@@ -1986,8 +2095,8 @@ And the grounded complement — asked, and refused:
1986
2095
  `corpus` by `bun run explain:token-census:readme` and gated against it by
1987
2096
  `bun run explain:token-census:readme:check`; the fixture itself is gated
1988
2097
  against a fresh corpus run by `bun run explain:token-census:check`. Of
1989
- 1,426,731 analyzed bytes, 1,022,782 are classified (71.69%), the remaining
1990
- 403,949 are `unclassified`. The census emits 241,279 tokens (avg 254.5 per
2098
+ 1,426,731 analyzed bytes, 1,022,786 are classified (71.69%), the remaining
2099
+ 403,945 are `unclassified`. The census emits 241,282 tokens (avg 254.5 per
1991
2100
  script). Every byte belongs to exactly one token — sorted by `start`, no
1992
2101
  gaps, no overlaps, `join(slice) === input` — and the `class` field is the
1993
2102
  vocabulary #264 B5 settled. Each B2 fill should move the classified
@@ -2468,22 +2577,317 @@ last byte. The `$` sigil and the `[` / `]` of a substitution stay `string` on
2468
2577
  the same ground — `structure.subcommands` already publishes the resolution, and
2469
2578
  its `innerSpan` locates the brackets around it.
2470
2579
 
2471
- ### Designed, not implemented (the CLI surface, #202b)
2472
-
2473
- `src/cli/explain.ts` ships the offline command; implemented flags are generated
2474
- into [`docs/CLI.md`](../../docs/CLI.md) and are not restated here. What the
2475
- [Surface](#surface-option-a--decided) table above describes and the command does
2476
- **not** yet accept:
2477
-
2478
- | Flag / form | Phase | Why it is not accepted yet |
2479
- | ----------- | ----- | -------------------------- |
2480
- | `explain <router> '<input>'` | 2 | The grammar is parsed and **refused** with `usage/not-implemented`, naming the router it did not contact. Running the offline analysis under an invocation that asked for probes would report `mode: "offline"` about a request for more. |
2481
- | `--cursor <byte>` | 2 | It positions `--complete`, which enumerates nothing offline. A cursor with no candidate surface to position is a flag with no effect. |
2482
- | `--full` | 3 | It lifts smart-sizing limits, and the offline result is never truncated (`input.truncated` is always `false`). Same rule: no accepted flag is a no-op. |
2483
-
2484
- `--complete` and `--schema` **are** accepted, because they have an offline
2485
- behavior the spec names: they enumerate nothing and emit the live-target tip.
2486
- That is the difference — an accepted flag must do something observable.
2580
+ ### Live probe foundation (#491)
2581
+
2582
+ `centrs explain <router> '<input>'` (or `--quickchr <name>` / `--host <host>`)
2583
+ adds read-only device evidence over REST or native API. Library callers use
2584
+ `explainLiveEnvelope({ input, targetInput, via, ... }, options)` from the root
2585
+ package; the browser-safe `@tikoci/centrs/explain` entry remains offline.
2586
+ Connection/authentication settings use the existing resolver and provider
2587
+ precedence. A pinned unsupported transport is refused. No fan-out or protocol
2588
+ fallback is performed. `AbortSignal` cancels library probes.
2589
+ An explicitly supplied connection flag such as `--via`, `--password` or
2590
+ `--timeout` without `<router>`, `--host` or `--quickchr` is a usage error before
2591
+ input is read. Connection defaults from environment and config remain inert in
2592
+ offline mode.
2593
+ Text output summarizes live observations; `--json` retains full parse and
2594
+ per-byte highlight evidence.
2595
+
2596
+ `data.canonical`, structure, symbols, values and optional tokens retain the
2597
+ offline result. Live observations are additional facts:
2598
+
2599
+ - `data.live.environment` records the observed version, architecture and board.
2600
+ - `data.live.parse` carries the printed parse result, rejection classification
2601
+ and authoritative line/byte-column when reported. Only an escaped
2602
+ `:put [:parse "…"]` wrapper runs; the analyzed input, substitutions and
2603
+ deferred bodies are never executed.
2604
+ - `data.live.highlight` retains raw device classes. Additional `data.spans`
2605
+ cite its evidence and use centrs classes: `dir`, `cmd`, `arg`, `escaped`,
2606
+ comments and variable classes; device state, syntax-meta, error/obsolete and
2607
+ unknown classes map to `unclassified`. Error/obsolete markers produce separate
2608
+ diagnostics. Offline and live spans can overlap; distinguish them by `ev`.
2609
+ - `data.live.arguments[]` contains `path`, `names`, `statementIds` and `ev` for
2610
+ command-level `request=child`. Repeated paths share a probe. Names confirm what
2611
+ the device exposes; they do not rewrite offline arguments, transport eligibility
2612
+ or lexical bindings. The highlight obsolete marker distinguishes `name x`
2613
+ from the two flags `detail without-paging` (example 48).
2614
+
2615
+ Every live evidence entry identifies its probe, observation time, known version
2616
+ and acquisition outcome. Input probes include SHA-256 and normalization;
2617
+ `coverage` identifies the submitted byte range for a usable response, not proof
2618
+ that every byte is syntactically valid. Parse uses original UTF-8 input;
2619
+ highlight uses the existing one-SUB-per-non-ASCII-byte normalization. Its facts
2620
+ therefore describe that explicit variant; both spaces align with `positionMap`.
2621
+ Parse can stop at its first error; highlight `none` remains unclassified.
2622
+
2623
+ Probe outcomes distinguish `ok`, `empty`, `unsupported`, `skipped`, `timeout`,
2624
+ `auth-error`, `transport-error`, `interrupted` and `invalid-response`. A parse
2625
+ rejection is an acquired response (`ok`) plus a diagnostic, not a transport
2626
+ failure. A failed/skipped probe adds a warning diagnostic; acquired offline and
2627
+ live facts remain available. `ok:true` means analysis produced a result, never
2628
+ that all probes succeeded. Resolver/usage failures and transport/authentication/interruption failures before
2629
+ any input probe yields a result remain `ok:false`.
2630
+ `meta.operation.mode` is `live`; no execution-validation verdict is invented:
2631
+ `meta.validation` is `{ enabled: false }`, offline and live, because explain
2632
+ is not a gate (#535). `runtimeAcceptance` always remains `not-proven`.
2633
+
2634
+ Work is bounded by the resolved per-request timeout, 32 distinct command paths,
2635
+ 32,767 bytes for highlight and 65,536 bytes for parse. Larger probes are skipped
2636
+ with their reason; no silent prefix is submitted and `input.truncated` stays
2637
+ false. A transport/authentication failure stops later requests. Scripting-path
2638
+ discovery is conservatively skipped before 7.21.4 or on unknown versions. No
2639
+ command-level completion, user-expression evaluation or related
2640
+ configuration-table collection is part of this slice. `--schema` and
2641
+ `--complete` add the probes in the next section, and the value-type probes
2642
+ after it run on every live call.
2643
+
2644
+ Committed controls are
2645
+ [`live-probes.stable.json`](../../test/fixtures/explain/live-probes.stable.json)
2646
+ (7.24.5) and
2647
+ [`live-probes.long-term.json`](../../test/fixtures/explain/live-probes.long-term.json)
2648
+ (7.23.8), both CHR reporting `x86_64`, with package manifests, input hashes,
2649
+ observation times, raw parse/highlight output and projected child names. Each
2650
+ fixture records its capture command. `test/unit/explain-live.test.ts` replays
2651
+ the parse classifier and source-aligned highlighter against these controls;
2652
+ `test/integration/explain-live.test.ts` repeats the probes on a real CHR and
2653
+ checks that the analyzed identity-setting command did not run.
2654
+
2655
+ ### Live describe and completion (#492)
2656
+
2657
+ `--schema` and `--complete` work on the statement at the cursor. The cursor
2658
+ defaults to the end of the input. `--cursor <byte>` is a UTF-8 byte offset,
2659
+ which is also the analyzed and device byte offset. Library callers may pass
2660
+ `cursor: { line, character }` (0-based, UTF-16) instead. A cursor inside a
2661
+ multi-byte character moves back to its first byte. A cursor outside the input
2662
+ is [`usage/invalid-cursor`](../../docs/errors/usage/invalid-cursor.md), raised
2663
+ before connecting. `--cursor` or `--full` without a facet is
2664
+ `usage/conflicting-flags`. Offline, both facets still return the live-target
2665
+ tip and enumerate nothing.
2666
+
2667
+ **Probe shapes.** Grounded on CHR 7.12.2, 7.20.8, 7.23.8 and 7.24.5 over REST
2668
+ and native API:
2669
+
2670
+ - `child` names what exists at a path and resolves unique abbreviations
2671
+ (`ip,addr` answers as `address`). It is safe on every build tried.
2672
+ - `syntax` is sent only for an **argument** path (`ip,address,set,address`,
2673
+ `put,message`). That shape answers in about 25 ms on every build, scripting
2674
+ keywords included.
2675
+ - `completion` is sent only with `input=`, the text before the cursor. It is
2676
+ safe at every position tried, including scripting keywords and `do={`
2677
+ bodies on 7.20.8.
2678
+ - Never sent: `syntax` or `completion` with `path=` set to a **command**
2679
+ (`ip,address,set`). On 7.12.2 and 7.20.8 REST stops answering, and the
2680
+ requests queued behind it stall, for about 30 s. Native API closes the
2681
+ connection. 7.23.8 and later answer normally, but the rule is
2682
+ version-independent (`commands/api/AGENTS.md`). restraml's crawlers never
2683
+ meet this, because they send `syntax` to `arg` nodes only.
2684
+
2685
+ Every probe is an `evidence[]` entry with `facet: "schema" | "complete"`, its
2686
+ path, version and outcome. Facts cite it by `ev`. A facet probe that answers
2687
+ `empty` is a result, so it adds no warning diagnostic. Other failed outcomes
2688
+ warn as in the foundation, and a transport or authentication failure stops
2689
+ later probes.
2690
+
2691
+ **`data.schema`** describes one path: the resolved statement, or an absolute
2692
+ slash path as typed (`/ip/addr`). The `child` self row decides `node`:
2693
+
2694
+ - `menu`: `verbs[]` and `menus[]`. Each child is expanded by one more `child`
2695
+ probe into `arguments` (verb) or `verbs`/`menus` (submenu) counts.
2696
+ - `command`: `arguments[]`, each with its argument-level `syntax` rows. A
2697
+ flag's blank row is dropped. For `print`, `proplist` holds the output-field
2698
+ names from `print proplist=` (`source: "print-proplist"`). A singleton
2699
+ settings menu offers none there, so `get value-name=` answers instead
2700
+ (`source: "get-value-name"`; `/ip/dns`, `/system/identity`). These are names
2701
+ a print can show, **not print rows**.
2702
+ - `argument`: its `syntax` rows.
2703
+ - `unknown`: the device reports nothing there, or the statement at the cursor
2704
+ is neither resolved nor an absolute path (`reason`).
2705
+
2706
+ Syntax rows are kept verbatim (`symbol`, RouterOS `symbol-type` as `kind`,
2707
+ `text`, `nested` as `depth`). They are declared help, such as
2708
+ `A.B.C.D (IP address)` or `input | forward | output` beside `string value`.
2709
+ They are not the `schemaType` value fact, which normalizes the same rows per
2710
+ value ([Live value types](#live-value-types-225)).
2711
+
2712
+ **Smart sizing.** By default each list shows up to 64 names, and expansion
2713
+ runs only when the listed children fit in 16 probes (a menu's children, or a
2714
+ command's arguments for their `syntax`). Otherwise names are listed without
2715
+ expansion. `counts` always holds the full sizes, `truncated: true`, `next[]`
2716
+ names up to three narrower inputs, and the envelope carries
2717
+ `tip/explain-schema-truncated`. On CHR 7.24.5, `/ip` (2 verbs, 29 menus) and
2718
+ `/ip/address/print` (21 arguments) truncate; `/ip/address` (12 verbs) and
2719
+ `/ip/firewall/filter` (16) do not. `--full` raises the limits to 1024 names and
2720
+ 128 probes. That is the safety ceiling, not unlimited.
2721
+
2722
+ **`data.completion`** reports `cursor`, `at` (original line/character),
2723
+ `replace` (the bytes a candidate replaces, ending at the cursor), `prefix`,
2724
+ `context`, `candidates[]`, `count` and `truncated`. Candidates are capped like
2725
+ names. They are the device's own: one input completion of the text before the
2726
+ cursor, keeping shown, non-syntax rows. The row `offset` sets `replace`. The
2727
+ navigation punctuation (`/`, `:`, `..`) offered everywhere is left out. So a
2728
+ relative path, a block, a `where` clause or an unclosed `[` completes exactly
2729
+ as on the console: `/ip { :put 1; address add comm` → `comment`, and
2730
+ `/ip address⏎` → that menu's verbs. Where the device offers nothing, neither
2731
+ does explain: `do={ add comm` runs at root, so `add` is unknown there.
2732
+
2733
+ The `context` comes from the device highlight of the same text. When the
2734
+ cursor is not at the end, that is one extra highlight probe whose `coverage`
2735
+ says so.
2736
+
2737
+ - `path`: in a `dir`/`cmd` word, after a menu, or at the start of a line.
2738
+ Candidates are `menu` or `verb` by row style. A word the device cannot
2739
+ resolve there is `obj-inactive`, and the token before it gives its role:
2740
+ `/ip/address re` → `remove`, `reset`, and `:lo` → `local`, `lock`, `log`.
2741
+ - `argument-name`: in an `arg` word, or after a command, flag or value.
2742
+ `match: "exact"` marks a complete name. `takesValue` is whether the same
2743
+ answer offers `=` at the cursor: `comment` does, and the flag `detail` does
2744
+ not.
2745
+ - `variable`: a `where` clause, whose property names RouterOS scopes as
2746
+ variables (`where comm` → `comment`).
2747
+ - `value`: after `name=`. Candidates may include configuration state, such as
2748
+ interface names. They are never a closed set:
2749
+ `chain=` offers `forward`, `input` and `output`, but custom chains are legal.
2750
+ For a command argument, `syntax` carries its declared type text, so a
2751
+ free-form `comment=` returns no candidates and `string value`. A `where`
2752
+ value (`disabled=` → `no`, `yes`) has no argument path, so no `syntax` is
2753
+ read.
2754
+ - `unsupported` with a `reason`: strings, the obsolete spaced `name value`
2755
+ spelling, and other tokens the device does not class as a path, name or
2756
+ value.
2757
+
2758
+ Every candidate is an observation of the completion probe its `ev` cites.
2759
+ The declared side is `data.schema`: `child` names and argument `syntax`. So
2760
+ completion carries no second provenance label, and nothing in it is a closed
2761
+ set. `path` names the command at the
2762
+ cursor when it is known. It comes from the offline statement or `[…]`
2763
+ substitution when that resolved, otherwise from the device's `dir`/`cmd` runs.
2764
+ The command is needed only for a value's `syntax`. When it is not visible (a
2765
+ relative path after `;`), the candidates are unaffected and `syntax` is
2766
+ omitted.
2767
+
2768
+ The verdict still judges the input as written, and the two probes agree on
2769
+ it. `/ip/address add comm` is rejected by `:parse` (`expected end of command`
2770
+ at byte 16), and the highlight marks the same byte `error`. So the result
2771
+ fails even while its candidates are published. An assist loop that only wants
2772
+ candidates passes `--fail-on never`.
2773
+
2774
+ Committed controls are
2775
+ [`live-facets.stable.json`](../../test/fixtures/explain/live-facets.stable.json)
2776
+ (7.24.5) and
2777
+ [`live-facets.long-term.json`](../../test/fixtures/explain/live-facets.long-term.json)
2778
+ (7.23.8). They hold every recorded call and its raw answer, plus the projected
2779
+ result. `test/unit/explain-live-facets.test.ts` replays them.
2780
+ `test/integration/explain-live-facets.test.ts` asserts the examples on both
2781
+ transports and regenerates the captures when `CENTRS_EXPLAIN_FACETS_CAPTURE`
2782
+ names an output file.
2783
+
2784
+ Not covered here: MCP (#223), related print rows and topic checks (#493),
2785
+ and per-target caching of describe results (open 3).
2786
+
2787
+ ### Live value types (#225)
2788
+
2789
+ Every live call fills the two live homes on `data.values.occurrences[].facts`
2790
+ that offline leaves empty. Neither changes the verdict or
2791
+ `runtimeAcceptance`, and neither becomes a diagnostic. Both probes are
2792
+ `evidence[]` entries with `facet: "types"` and `basis: "derived"`; an `empty`
2793
+ answer is a result, not a warning. They run after `--schema`/`--complete`.
2794
+
2795
+ **`observedType: { value, rendered, ev }`** is the type the device's parser
2796
+ visibly converted a bare literal to. `:parse` prints a string unquoted, so
2797
+ `:local a 123` and `:local b "123"` both render `value=123`. A rendering is
2798
+ therefore evidence only when it CHANGED the literal:
2799
+
2800
+ | Literal | Device rendering | `observedType` |
2801
+ | ------- | ---------------- | -------------- |
2802
+ | `2.2`, `2.2/8` | `2.0.0.2`, `2.0.0.2/8` | `ip`, `ip-prefix` |
2803
+ | `1:0:0::1` | `1::1` | `ip6` |
2804
+ | `0x10`, `0123`, `99999999999999999999` | `16`, `123`, `9223372036854775807` | `num` |
2805
+ | `yes`, `no` | `true`, `false` | `bool` |
2806
+ | `200ms`, `1d`, `1:2`, `10000w` | `00:00:00.200`, `1d00:00:00`, `01:02:00`, `10000w00:00:00` | `time` |
2807
+ | `123`, `1.1.1.1`, `true`, `00:00:02`, `"2.2"` | unchanged | absent |
2808
+ | `100000w`, `abc`, `1e5`, `300.1.1.1` | unchanged | absent |
2809
+ | member `{abc}`, `{-1}` | `$abc`, `(- 1)` | absent |
2810
+
2811
+ The literal is not looked up in the IL of the analyzed input, where a quoted
2812
+ `"x;y=1"` prints exactly like two arguments. Each bare literal is re-parsed
2813
+ alone: `:local vN <lit>` for a scripting operand, `:local vN {<lit>}` for an
2814
+ array member, `;`-joined into one escaped `:put [:parse "…"]` per context.
2815
+ `:put <lit>` converts exactly as `:local` does. Only literals of the bytes
2816
+ `A-Z a-z 0-9 . _ : / -` are sent, so no literal can leave its slot, and a
2817
+ quoted value never qualifies. One hard-error member (`{*1}`) rejects a whole
2818
+ batch, so the two contexts are separate probes and a rejected batch is
2819
+ `invalid-response` with the device's message; its literals abstain. A batch is
2820
+ at most 256 literals, otherwise `skipped`.
2821
+
2822
+ A scripting directive's named argument converts exactly as an operand
2823
+ (`:for i from=0x10` → `from=16`, `:put message=yes` → `message=true`), so it
2824
+ joins the operand batch. A directive's NAME slot is a variable and never
2825
+ converts: `:local yes 2` renders `name=$yes`, `:for yes from=1` renders
2826
+ `counter=$yes` and `:local name=no` renders `name=$no`. So a value the symbol
2827
+ view claims as a name, or a directive's `name=`/`counter=` argument, is never
2828
+ probed.
2829
+
2830
+ Menu-command arguments are never observed. Their own parser converts them
2831
+ and prints a per-argument encoding (`src-address=!1.1.1.1` →
2832
+ `true;1.1.1.1`, `address=2.2` → `2.0.0.2;`), and a quoted value converts only
2833
+ at run time (`address="2.2"` stays `2.2`). That slot is `schemaType`'s. `op`
2834
+ has no producer: `(>[:return 1])` and `(>{"a"=1})` parse identically, and only
2835
+ `:typeof` separates them.
2836
+
2837
+ **`schemaType: { values, list?, declared, ev }`** is the argument's declared
2838
+ type. For each `name=value` of a resolved menu command, the name is resolved
2839
+ against that command's live `child` names, exactly or as a unique prefix
2840
+ (`dist=` → `distance`). One `syntax` probe per distinct argument path
2841
+ (`ip,route,add,distance`) reads the declaration; at most 32 per call, later
2842
+ ones `skipped`. An ambiguous or unknown name sends no probe. `syntax` is never
2843
+ sent to a command path (see [Live describe and completion](#live-describe-and-completion-492)).
2844
+
2845
+ The rows are a small grammar (`[!]Range`, `Address[%Interface][@VRF]`), so
2846
+ `values` lists the LEAF types the declaration mentions, not alternatives of
2847
+ one value. They use the shape-hint names so the two axes compare directly,
2848
+ plus three schema-only terms:
2849
+
2850
+ | Device leaf | Term |
2851
+ | ----------- | ---- |
2852
+ | `(integer number)`, `(decimal number)`, `integer value`, `0..65535` | `num` |
2853
+ | `(time interval)`, `time interval` | `time` |
2854
+ | `(IP address)`, `IPv4 address`, `A.B.C.D` | `ip` |
2855
+ | `IPv6 address` | `ip6` |
2856
+ | `(IP prefix)`, `(IPv6 prefix)` | `ip-prefix`, `ip6-prefix` |
2857
+ | `(IP address range)` | `ip-range` |
2858
+ | `(MAC address)` | `mac` |
2859
+ | `string value…`, `hexadecimal string value…`, `script`, `filename` | `str` |
2860
+ | a listed word set (`input \| forward \| output`, `ether1 \| lo \| ...`) | `enum` |
2861
+
2862
+ `enum` is never a closed set: the device lists the current configuration
2863
+ names (`ether1 | lo`) exactly like keywords, and ends either kind with `...`
2864
+ when the list is long (`ftp | h323 | … | ...`). `list: true` is present when
2865
+ the declaration spells a repetition (`[,Type*]`, `(max 15 times)`); its
2866
+ absence is not a claim — `servers` (`Address[@VRF]`) splits on a comma
2867
+ without saying so. A keyword's help row (`tcp` → `transmission control
2868
+ protocol`) is not a leaf, and a blank placeholder beside a real leaf is
2869
+ skipped. The fact is ABSENT when the declaration is blank (every flag and
2870
+ boolean: `disabled`, `copy-from`) or when any leaf is `see documentation`
2871
+ (`/ipv6/address add address`). `declared` is the device's top-level text,
2872
+ verbatim (`Address[@VRF]`, `Num`).
2873
+
2874
+ The vocabulary also names `bool` and `name` for a second schema source: the
2875
+ CLI Reference's `Type` cells (`bool`, `iface_enum`, `ipAddr`, `macAddr`,
2876
+ `ubit (…)`), which declare the booleans and object references inspect leaves
2877
+ blank or prints as words. A CLI Reference producer would fill the same fact,
2878
+ told apart by its `ev`; delivering those tables is decision 3 (#501).
2879
+
2880
+ Grounded on CHR 7.24.5 and 7.23.8 over REST and native API, identical on both:
2881
+ a 40-command crawl (863 arguments) set the leaf table (571 project, 282 are
2882
+ blank, 7 cite `see documentation` and 3 list no names), and the conversion
2883
+ matrix is recorded raw in
2884
+ [`live-types.stable.json`](../../test/fixtures/explain/live-types.stable.json)
2885
+ and [`live-types.long-term.json`](../../test/fixtures/explain/live-types.long-term.json).
2886
+ `test/unit/explain-live-types.test.ts` replays them and pins the matrix
2887
+ reading; `test/integration/explain-live-types.test.ts` asserts examples 52–53
2888
+ on both transports and that every executed script is a `:parse` wrapper and
2889
+ every `syntax` probe names a discovered argument. It regenerates the captures
2890
+ when `CENTRS_EXPLAIN_TYPES_CAPTURE` names an output file.
2487
2891
 
2488
2892
  ## MCP and library surfaces
2489
2893
 
@@ -2619,6 +3023,202 @@ a process boundary. A B5 rename is therefore a visible break here rather than a
2619
3023
  silent one — which is the point of settling the vocabulary before an external
2620
3024
  consumer commits to it.
2621
3025
 
3026
+ ## Topic checks and related live reads (#493, design — no implementation)
3027
+
3028
+ This section is the bounded design #493 asks for. It proposes shapes, ownership,
3029
+ and acceptance gates; **no topic check, special-command rule, or contextual
3030
+ read ships with it**. Each check needs its own accepted/rejected CHR controls
3031
+ and committed evidence before an implementation slice lands, and every new
3032
+ RouterOS semantic claim is grounded then — not here. The three examples below
3033
+ are **SYNTHETIC shape illustrations, not device claims**: they show the envelope
3034
+ a check would produce, with invented content, so reviewers can judge the
3035
+ contract without mistaking illustration for evidence.
3036
+
3037
+ **Ownership: one rule table, three consumers.** Fact and check definitions live
3038
+ in one module beside the analyzer (sibling of `src/explain/write.ts`, which is
3039
+ the precedent: an explain-only inference over the shipped structure). The
3040
+ CLI renders it, the library returns it, and the MCP adapter reshapes the same
3041
+ object — no copies of rule logic per surface. Checks evaluate against the
3042
+ **statement tree** (`structure.statements[]` ids plus `parent`/`owner` links),
3043
+ never against byte containment: a finding anchors to a statement id and its
3044
+ span, so a reordered document moves the finding with its statement.
3045
+
3046
+ **Findings reuse the shipped envelope — no new finding object.** A check emits
3047
+ what explain already emits: an `ExplainDiagnostic` (`code`, `severity`,
3048
+ `message`, `span`, `ev`) for the finding, the `ExplainEvidence` entry it cites
3049
+ (`id`, `source`, `probe`, `basis`, `outcome`, `statementIds`) for how it was
3050
+ decided and which statement it anchors to, and a `tip` (`code`, `message`,
3051
+ `fix`) for the next step. Codes follow `explain/<source>/<slug>`, with the
3052
+ check family as the slug prefix (`topic-…`, `special-command-…`). Topic
3053
+ findings are `info`: like the pasted-prompt tip, advice never moves
3054
+ `data.verdict`, which stays the validity answer. A check that should raise the
3055
+ verdict is a separate, explicitly decided change, not a default. Certainty is
3056
+ the cited evidence's `basis` — `heuristic` for a ratified rule over the
3057
+ offline structure (the `containsWrite` precedent), `derived` once it rests on
3058
+ live responses — so how sure a finding is reads from the same field as every
3059
+ other explain fact.
3060
+
3061
+ **What is decided where.** Source-only: structural facts the canonicalizer
3062
+ already proves (unresolved statements, write-shape, symbol bindings). Live
3063
+ schema: what a menu accepts (argument names, enums) — only `--schema` /
3064
+ `--complete` against a named router, never a bundled snapshot (decision 3).
3065
+ Device observations: current state (`print` rows) — only through an explicitly
3066
+ requested contextual read (below). Partial script input is never treated as
3067
+ final device state, and a finding never grants execution permission: advice
3068
+ and the execution gate are separate answers, and a lost read response is
3069
+ reported as a failed read, never as a successful execution.
3070
+
3071
+ **Contextual reads are opt-in and bounded.** The default offline entry stays
3072
+ network-free; naming a router does not authorize reads. A contextual read
3073
+ fires only under an explicit flag (name TBD, e.g. `--with-print`), one bounded
3074
+ `print` per confidently resolved menu, projected and truncated, each reported
3075
+ independently with its read command, time, projection, and truncation — so an
3076
+ agent can distinguish current observed state from the proposed script. The
3077
+ target is the envelope's `meta.target`, not a per-read field. Deduplication is by (statement id, resolved path): two
3078
+ statements in one menu share one read, and a failed read fails alone. The
3079
+ read is a validated centrs `retrieve` on the resolved path — never the user's
3080
+ `[find]`, expression, or analyzed script executed to get context.
3081
+
3082
+ **Secrets follow the existing redaction contract.** Related reads ride the
3083
+ redaction paths from #359: secret-bearing menus and verbose file output are
3084
+ high-risk surfaces, so a contextual read defaults to a non-sensitive
3085
+ projection, names its projection explicitly, and never exports key material
3086
+ into advice artifacts or logs. A check whose finding would need a sensitive
3087
+ field states that need in its definition and is refused without an explicit
3088
+ sensitivity opt-in — no automatic sensitive exports.
3089
+
3090
+ **Shape 1 — a grounded topic finding (synthetic illustration).**
3091
+ Input `SYNTHETIC-1` (illustrative only):
3092
+ `/ip service set telnet disabled=no` — a statement the analyzer resolves with
3093
+ id `s0`, span 0–34. A management-plane topic check (definition owned beside
3094
+ the analyzer, evaluated against `s0`) adds one diagnostic, the evidence entry
3095
+ it cites, and a tip; the ids and the check's `probe` name are invented. A
3096
+ partial envelope — only the fields the finding adds or reads are shown:
3097
+
3098
+ ```json
3099
+ {
3100
+ "data": {
3101
+ "verdict": "pass",
3102
+ "diagnostics": [
3103
+ {
3104
+ "code": "explain/canonicalizer/topic-plaintext-management-service",
3105
+ "severity": "info",
3106
+ "message": "This enables telnet, a plaintext management service.",
3107
+ "span": { "start": 0, "end": 34 },
3108
+ "ev": "e10"
3109
+ }
3110
+ ],
3111
+ "evidence": [
3112
+ {
3113
+ "id": "e10",
3114
+ "source": "canonicalizer",
3115
+ "probe": "topicPlaintextManagementService",
3116
+ "basis": "heuristic",
3117
+ "outcome": "ok",
3118
+ "statementIds": ["s0"]
3119
+ }
3120
+ ]
3121
+ },
3122
+ "tips": [
3123
+ {
3124
+ "code": "tip/explain-topic-plaintext-management-service",
3125
+ "message": "Telnet sends credentials in clear text.",
3126
+ "fix": "Disable the plaintext service or restrict it to a management address list."
3127
+ }
3128
+ ]
3129
+ }
3130
+ ```
3131
+
3132
+ The verdict stays `pass`; probe outcome and `runtimeAcceptance` are untouched —
3133
+ the finding does not claim the router accepted anything.
3134
+
3135
+ **Shape 2 — a special-command fact (synthetic illustration).** #363 records
3136
+ the real cases (deferred there, not duplicated here); the shape is Shape 1's,
3137
+ with the `special-command-` slug family and a consequence, not a
3138
+ configuration judgement. Illustrative only:
3139
+
3140
+ ```json
3141
+ {
3142
+ "code": "explain/canonicalizer/special-command-connection-loss",
3143
+ "severity": "info",
3144
+ "message": "This command can drop the session that runs it.",
3145
+ "span": { "start": 64, "end": 79 },
3146
+ "ev": "e11"
3147
+ }
3148
+ ```
3149
+
3150
+ `e11` cites `statementIds: ["s2"]`, and the paired tip's `fix` says to run it
3151
+ from a path that survives the change.
3152
+
3153
+ No isolated device-mode table ships first (the #363 deferral stands); a
3154
+ special-command fact names its consequence and its evidence, nothing more.
3155
+
3156
+ **Shape 3 — an explicitly requested contextual read (synthetic illustration).**
3157
+ Input (illustrative): `/ip address add address=198.51.100.1/32 interface=ether1`
3158
+ with an explicit `--with-print`-style opt-in. The read follows the live
3159
+ argument-name precedent (`data.live.arguments[]` cites a `live-inspect`
3160
+ evidence entry): one evidence entry per read, and the rows in `data.live`
3161
+ citing it, independent of the analysis verdict. A partial `data` object —
3162
+ `evidence` is `data.evidence`, `live` is `data.live`:
3163
+
3164
+ ```json
3165
+ {
3166
+ "evidence": [
3167
+ {
3168
+ "id": "e12",
3169
+ "source": "live-read",
3170
+ "probe": "print",
3171
+ "basis": "direct",
3172
+ "outcome": "ok",
3173
+ "version": "SYNTHETIC-version",
3174
+ "observedAt": "SYNTHETIC-timestamp",
3175
+ "statementIds": ["s0"],
3176
+ "path": "/ip/address",
3177
+ "projection": ["address", "interface", "disabled"],
3178
+ "truncation": { "returned": 3, "total": 12 }
3179
+ }
3180
+ ],
3181
+ "live": {
3182
+ "reads": [
3183
+ {
3184
+ "ev": "e12",
3185
+ "path": "/ip/address",
3186
+ "statementIds": ["s0"],
3187
+ "rows": [
3188
+ { "address": "192.0.2.1/24", "interface": "ether1", "disabled": "false" },
3189
+ { "address": "198.51.100.2/24", "interface": "ether2", "disabled": "false" },
3190
+ { "address": "203.0.113.1/24", "interface": "bridge", "disabled": "true" }
3191
+ ]
3192
+ }
3193
+ ]
3194
+ }
3195
+ }
3196
+ ```
3197
+
3198
+ The target is the envelope's existing `meta.target`, not a per-read field.
3199
+ Three additions are proposed, not shipped: the `live-read` evidence source,
3200
+ the `projection` and `truncation` evidence fields, and `data.live.reads[]`.
3201
+ Originating statement and path, time, projection, and truncation are all
3202
+ present, so observed state cannot be mistaken for the proposed script. A
3203
+ failed read reports its `outcome` with `reason`/`errorCode` and fails alone.
3204
+
3205
+ **Acceptance controls and dependencies before implementation slices.**
3206
+
3207
+ - Each check ships with accepted/rejected CHR controls and committed evidence;
3208
+ synthetic shapes above are replaced by measured rows, never promoted as-is.
3209
+ - Controls assert statement-id anchoring (finding moves with its statement),
3210
+ verdict independence (a finding leaves `data.verdict` unchanged), the cited
3211
+ evidence's `basis`, and read independence (one failed read does not fail
3212
+ the analysis or sibling reads).
3213
+ - A check that needs a sensitive field is refused without the sensitivity
3214
+ opt-in; the redaction contract is asserted, not assumed.
3215
+ - Dependencies: published offline readings stay honest (#211, #350, #351,
3216
+ #358, #371); live path/argument/probe facts come from #236; live value
3217
+ types are distinct evidence (#225); the MCP result migration is separate
3218
+ (#223); text stdout/stderr routing is unchanged (#477); device-mode
3219
+ provisioning stays in #95. No new plugin DSL or framework is preselected —
3220
+ existing `warnings`/`tips` and explain diagnostics are reused where they fit.
3221
+
2622
3222
  ## Non-goals
2623
3223
 
2624
3224
  - No LSP server in centrs — the LSP is a consumer the library must support,
@@ -2884,14 +3484,22 @@ a missing mandatory value, and a name collision. That was verified rather than
2884
3484
  assumed — each blocker statement was replayed through `:put [:parse …]` and
2885
3485
  `/console/inspect request=highlight` on both versions and **accepted every
2886
3486
  time**, with controls (`no-such-attr=`, an unterminated string, a nonexistent
2887
- menu) correctly rejected. So these results carry **no** implication about
3487
+ menu) correctly rejected by `:parse`. `highlight` is the weaker of the two: it
3488
+ marks only the nonexistent menu with an `error` class, reading the bad
3489
+ attribute as `obj-inactive` and the unterminated string as ordinary tokens, so
3490
+ the controls' rejection rests on `:parse`. So these results carry **no** implication about
2888
3491
  whether `explain` can parse the forms, and they do not license claims about the
2889
3492
  export styles in general: the captures come from one synthetic configuration on
2890
3493
  CHR, which has a single NIC and no switch or wireless hardware, so its defconf
2891
3494
  and its available menus differ from any real device's. Treat the provenance
2892
3495
  label as **L1-emitted, L2-refuted for these captures on CHR**. Harness and
2893
- per-document results: `.scratch/explain-lab-q203-import-verify.ts`;
2894
- validity check: `.scratch/q203-blocker-validity.ts`.
3496
+ per-document results: the q203 import-verify lab (history — see #203; its
3497
+ scripts and corpora were never promoted, so the counts above rest on those
3498
+ lab captures). Validity check: `bun run explain:probe:import-blocker-validity`
3499
+ (promoted from the q203 lab for #272; replays the four blockers plus the
3500
+ three rejecting controls through `:parse` and `highlight` on a disposable
3501
+ CHR per version; re-run on 7.23.2 and 7.24rc2 for #568, 6/6 and 7/7 rows
3502
+ agree).
2895
3503
 
2896
3504
  **Non-blocking / deferred (not ratification-gating):** Q5 expression depth, Q7
2897
3505
  tokenizer corners, Q9 potential-command taxonomy, Q12 span-vocabulary draft —
@@ -2988,9 +3596,10 @@ test framework is part of this baseline.
2988
3596
 
2989
3597
  ### Current and following capabilities
2990
3598
 
2991
- - **Current — live probes (#236, phase 2):** highlight + `:parse` and
2992
- completion/child/syntax over rest-api/native-api. B4's projection/agreement
2993
- informs this work; each live cell requires its own CHR-passing examples.
3599
+ - **Done — live probes (#236, phase 2):** highlight + `:parse` (#491),
3600
+ completion/child/syntax (#492) and live value types (#225) over
3601
+ rest-api/native-api, `CHR-passed` on stable and long-term.
3602
+ `runtimeAcceptance` stays `not-proven`.
2994
3603
  - **MCP alignment (#223):** the deliberate flat-to-rich response migration,
2995
3604
  coordinated with rosetta. Independent of the offline token vocabulary.
2996
3605
  - **Facet polish (phase 3):** live sizing, `--full`, completion/schema ergonomics.
@@ -3061,8 +3670,10 @@ round):
3061
3670
 
3062
3671
  Still open (implementation opens — none block ratification):
3063
3672
 
3064
- 1. Final flag names (`--full`, `--curl`, facet names) and the smart-sizing
3065
- thresholds (what counts as "cheap", what triggers truncation).
3673
+ 1. ~~Final flag names and smart-sizing thresholds.~~ Decided by #492:
3674
+ `--schema`, `--complete`, `--cursor <byte>` and `--full`; 64 names and 16
3675
+ expansion probes by default, and 1024 / 128 with `--full`
3676
+ ([Live describe and completion](#live-describe-and-completion-492)).
3066
3677
  2. The mapping table from RouterOS highlight classes to **colors**, and how
3067
3678
  unknown/new upstream classes degrade. Phase 0 pinned the raw inputs (Q13's
3068
3679
  per-occurrence highlight-class corpus; the observed 19 classes on 7.23.2 plus