@tikoci/centrs 0.1.3 → 0.1.4

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 (90) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/GLOSSARY.txt +213 -0
  3. package/LICENSE +21 -0
  4. package/README.md +36 -8
  5. package/commands/AGENTS.md +7 -1
  6. package/commands/api/README.md +19 -18
  7. package/commands/api/examples.md +15 -0
  8. package/commands/btest/README.md +21 -32
  9. package/commands/check/README.md +6 -1
  10. package/commands/discover/README.md +5 -11
  11. package/commands/execute/README.md +41 -29
  12. package/commands/execute/examples.md +41 -3
  13. package/commands/explain/README.md +1716 -0
  14. package/commands/explain/examples.md +606 -0
  15. package/commands/retrieve/README.md +25 -27
  16. package/commands/retrieve/examples.md +16 -0
  17. package/commands/transfer/README.md +28 -20
  18. package/commands/transfer/examples.md +29 -0
  19. package/docs/CLI.md +353 -0
  20. package/docs/CONSTITUTION.md +66 -15
  21. package/docs/MATRIX.md +38 -1
  22. package/docs/errors/README.md +3 -0
  23. package/docs/errors/quickchr/machine-not-found.md +22 -0
  24. package/docs/errors/quickchr/machine-stopped.md +20 -0
  25. package/docs/errors/quickchr/package-unavailable.md +27 -0
  26. package/docs/errors/quickchr/unsupported-via.md +35 -0
  27. package/docs/errors/quickchr/unsupported.md +35 -0
  28. package/docs/errors/routeros/version-unsupported.md +26 -0
  29. package/docs/errors/transport/provider-trust.md +29 -0
  30. package/docs/errors/usage/stdin-ignored.md +28 -0
  31. package/docs/errors/validation/unknown-attribute.md +37 -4
  32. package/package.json +45 -10
  33. package/src/api-fanout.ts +29 -14
  34. package/src/api.ts +97 -54
  35. package/src/cli/api.ts +25 -6
  36. package/src/cli/btest.ts +18 -2
  37. package/src/cli/execute.ts +25 -6
  38. package/src/cli/explain.ts +503 -0
  39. package/src/cli/retrieve.ts +33 -1
  40. package/src/cli/selection.ts +71 -11
  41. package/src/cli/settings.ts +2 -2
  42. package/src/cli/transfer.ts +23 -6
  43. package/src/cli.ts +15 -2
  44. package/src/core/envelope.ts +5 -2
  45. package/src/core/error-catalog.ts +40 -0
  46. package/src/core/preflight.ts +306 -0
  47. package/src/core/routeros-string.ts +24 -0
  48. package/src/errors.ts +1 -0
  49. package/src/execute-fanout.ts +29 -14
  50. package/src/execute.ts +114 -63
  51. package/src/explain/arg-tokens.ts +114 -0
  52. package/src/explain/args.ts +1403 -0
  53. package/src/explain/blocks.ts +132 -0
  54. package/src/explain/brace-slots.ts +143 -0
  55. package/src/explain/brace-tokens.ts +38 -0
  56. package/src/explain/catalog.ts +1345 -0
  57. package/src/explain/coordinates.ts +329 -0
  58. package/src/explain/defects.ts +217 -0
  59. package/src/explain/is-known-menu.ts +17 -0
  60. package/src/explain/menus.ts +664 -0
  61. package/src/explain/operator-tokens.ts +310 -0
  62. package/src/explain/operators.ts +458 -0
  63. package/src/explain/path-tokens.ts +150 -0
  64. package/src/explain/pathresolve.ts +1324 -0
  65. package/src/explain/quoted-string.ts +241 -0
  66. package/src/explain/scope-brace.ts +308 -0
  67. package/src/explain/segment.ts +757 -0
  68. package/src/explain/string-tokens.ts +67 -0
  69. package/src/explain/symbol-values.ts +520 -0
  70. package/src/explain/symbols.ts +1597 -0
  71. package/src/explain/token-ranges.ts +72 -0
  72. package/src/explain/transport.ts +497 -0
  73. package/src/explain/value-tokens.ts +80 -0
  74. package/src/explain/values.ts +357 -0
  75. package/src/explain/verbs.ts +64 -0
  76. package/src/explain/verbsplit.ts +813 -0
  77. package/src/explain/write.ts +815 -0
  78. package/src/explain.ts +2152 -0
  79. package/src/index.ts +206 -4
  80. package/src/protocols/mac-telnet-console.ts +61 -17
  81. package/src/resolver/facts.ts +1 -1
  82. package/src/resolver/index.ts +23 -4
  83. package/src/resolver/quickchr-provider.ts +597 -0
  84. package/src/resolver/selection.ts +99 -12
  85. package/src/resolver/service-endpoint.ts +155 -0
  86. package/src/resolver/target.ts +1 -1
  87. package/src/retrieve-fanout.ts +26 -12
  88. package/src/retrieve.ts +81 -18
  89. package/src/transfer-fanout.ts +42 -25
  90. package/src/transfer.ts +87 -44
@@ -0,0 +1,1716 @@
1
+ # explain
2
+
3
+ Analyze a RouterOS command **before** anything runs it: canonical form and
4
+ structure, LSP-like token spans and diagnostics, how the command would actually
5
+ run (`api`-able vs `execute`-required, including a rendered REST/`curl` form),
6
+ and — against a live device — what commands, arguments, and values are valid at
7
+ a path, with completion-style candidates for building the right command.
8
+ `explain` is the "E" of the explain → validate → run split (constitution:
9
+ validation): the cheap, safe knowledge tier in front of the runners
10
+ (`execute` / `api` / `retrieve`).
11
+
12
+ Status: `designed` over `rest-api` and `native-api` — the transports the live
13
+ inspection probes ride; every other cell is `—`. The offline mode is
14
+ protocol-connection-less, and **`centrs explain '<input>'` runs today** (issues #202b
15
+ and #202c): the row stays `designed` because the grid tracks protocol cells and
16
+ offline occupies none. See `docs/MATRIX.md` for the row. A first design round
17
+ (2026-07-19, recorded in #90) settled the surface shape and the offline model;
18
+ the **phase-0 canonicalization grounding lab (#185) is complete and this spec
19
+ is now ratified** — every ratification-gating question was answered with cited
20
+ evidence (see [Phase-0 ratification](#phase-0-ratification-185)). The findings
21
+ folded in below; a few of them **amend** the surface (tristate `containsWrite`,
22
+ an `ambiguous` canonicalizer verdict, a defect-region diagnostics contract, a
23
+ byte-count-preserving coordinate rule, the native-api `:parse` readout). Flag
24
+ names and sizing thresholds stay provisional — those are implementation opens,
25
+ not ratification blockers.
26
+ Load-bearing rules — envelope, errors, settings precedence, identity,
27
+ validation, protocol selection — live in
28
+ [`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md).
29
+
30
+ ## Why (grounding)
31
+
32
+ - `~/GitHub/bench-routeros-tools` (`REPORT.md`) grounds the split empirically:
33
+ agents need a cheap, safe knowledge/validation tier gating a thin runner, and
34
+ a handful of verbs over canonical `{path, verb, args}` beats per-command
35
+ tools. It also measured the **inspect-vs-runtime gap** (`/console/inspect`
36
+ accepted `blackhole=yes` where the runtime wanted the bare flag): schema
37
+ acceptance is necessary, never sufficient.
38
+ - The wire-format research is done and public:
39
+ [`routeros-syntax-inspection`](https://github.com/tikoci/routeros-skills/tree/main/routeros-syntax-inspection)
40
+ (probe selection, highlight/parseIL/completion/child/syntax semantics,
41
+ hazards, provenance rules) and
42
+ [`routeros-command-tree`](https://github.com/tikoci/routeros-skills/tree/main/routeros-command-tree)
43
+ (tree traversal, schema generation), both grounded by full-corpus captures in
44
+ [tikoci/lsp-routeros-ts `docs/`](https://github.com/tikoci/lsp-routeros-ts/tree/main/docs)
45
+ (913 scripts × 7.9.2/7.23.2/7.24rc2). This spec cites those references
46
+ rather than restating them; they are the canonical wire truth.
47
+ - Decisions locked in #90: `validate` stays **boolean** (the dry-run gate
48
+ inside `execute`/`api` and `centrs_validate`); `explain` is the surface that
49
+ carries richness; centrs is the **canonicalizer/explain owner** and
50
+ `lsp-routeros-ts` (and tikbook) eventually consume the centrs library
51
+ instead of owning their own analysis core.
52
+ - Boundary from rosetta B-0013 (#175), sharpened by the 2026-07-19 round:
53
+ rosetta owns *static, docs-grounded* explanation — prose, URLs, version
54
+ history, "what version supports what" — and never grows validate/run; centrs
55
+ owns canonicalization and anything device-aware. **centrs does not call
56
+ rosetta, and does not consume restraml/rosetta schema data, for now**: the
57
+ projects stay decoupled until centrs's final shape is known. At most,
58
+ `explain` output may *steer* toward rosetta in a tip (e.g. "for docs/version
59
+ history, ask rosetta"). Deeper integration (for example rosetta publishing
60
+ stable GitHub-Pages links centrs help could cite) is future rosetta-side
61
+ work, tracked there, not here.
62
+
63
+ ## Where `explain` sits
64
+
65
+ - **Never executes, never mutates.** All live probes (`/console/inspect`
66
+ highlight/completion/syntax/child and `:put [:parse …]`) are read-only
67
+ inspection. Execution-based probes (required-argument discovery via
68
+ deliberate errors) are out of scope — they belong to research tooling, not
69
+ this verb.
70
+ - **`validate` is unchanged.** `--validate` remains the boolean gate on the
71
+ runners; `explain` is what you call when you want to know *why*, or to build
72
+ the command in the first place. Internally they share machinery; externally
73
+ they stay distinct surfaces (constitution: validation).
74
+ - **The structured-mode gate stays authoritative.** `explain` exposes the
75
+ canonicalizer's verdict (`mode`, `writeShaped`) but must not widen what
76
+ counts as `structured` (constitution: canonicalizer ownership).
77
+ - **Passing `explain` is not a promise the router will accept the command.**
78
+ Every result carries this asymmetry explicitly (see
79
+ [Result shape](#result-shape-sketch)); only `execute`/`api` against a real
80
+ target proves runtime acceptance.
81
+ - **Library-first.** The deliverable is exported functions on `@tikoci/centrs`
82
+ that need no CLI and (offline) no CDB; the CLI verb and the MCP tool are
83
+ thin adapters. centrs ships no LSP server, but the library API must be able
84
+ to **support one**: hover, diagnostics, completion, and semantic-token needs
85
+ of `lsp-routeros-ts`/tikbook are first-class consumers of the export shape.
86
+
87
+ ## Canonicalization is the core
88
+
89
+ The engine is a **grown `canonicalizeExecuteCommand`** (decision: grow the
90
+ in-repo canonicalizer; rosetta's pure `canonicalize.ts` is reference material
91
+ to borrow from as needed, with **no sync obligation** — centrs solves a
92
+ different problem and is expected to diverge). Target capabilities, beyond
93
+ today's path/verb/args split and script-vs-structured gate:
94
+
95
+ - **Structure, not just segmentation.** Parse RouterOS input into an AST-like
96
+ structure so complex expressions, blocks, and scopes can be identified —
97
+ including multi-statement `.rsc` scripts.
98
+ - **Sub-command resolution with re-constituted paths.** In
99
+ `/ip/address remove [find comment=defconf]`, the inner `find` carries the
100
+ enclosing path (`ip,address`); the canonical structure exposes that inner
101
+ command with its resolved path rather than treating `[…]` as an opaque blob.
102
+ - **Symbol scopes follow RouterOS scope identity, not brace depth alone.** Names
103
+ are case-sensitive. Control-flow `do={…}` bodies share the enclosing scope,
104
+ but a named-function definition (`:local F do={…}` / `:global F do={…}`)
105
+ starts a closure: outer locals become parameters there, and a global must be
106
+ re-imported inside the body. If the parser cannot establish which body owns a
107
+ binding, it abstains instead of assigning a confident symbol class (Q13).
108
+ - **Transport classification — fail-closed.** For each statement, say how it
109
+ would actually run: `api-candidate` (representable as a structured `api`
110
+ operation, with the REST method/path/body), `execute` (script mode,
111
+ expressions, sub-commands), or `unknown` — each with a stated basis. The
112
+ canonicalizer alone cannot *prove* a REST mapping (the syntax-inspection map
113
+ keeps CLI→REST conversion separate from source parsing), so a ready-to-run
114
+ `curl` renders **only** where an explicit, tested mapping rule covers the
115
+ command family; anything else classifies `unknown` rather than promoting a
116
+ heuristic into executable output. The `centrs execute`/`centrs api`
117
+ invocation renders for either classified case. In particular, selector-based
118
+ writes route to `execute`, while selector-less `set` is `unknown` offline:
119
+ without a schema, `/ip/dns set …` (a singleton) and
120
+ `/ip/firewall/filter set …` (an id-bearing table) have the same shape. Live
121
+ evidence may lift the singleton case; offline never guesses it.
122
+ - **Mutation analysis is separate from the gate.** The execute gate's verdict
123
+ (`mode`, `writeShaped`) is reproduced **unchanged** — a bracketed
124
+ sub-command selector stays `mode: "script"` / `writeShaped: false`, exactly
125
+ as `canonicalizeExecuteCommand` and its anchor tests
126
+ (`test/unit/execute-canonicalize-contract.test.ts`) pin it. Richer
127
+ explain-only inference (an inner `remove` detected in structure) lands in a
128
+ **distinct** field, `structure.containsWrite` (basis `heuristic`) — never by
129
+ widening *or* reinterpreting `writeShaped`, so agents cannot mistake an
130
+ explain inference for a guard `execute` actually applies. **`containsWrite`
131
+ is three-valued `true | false | unknown`, not a boolean** (Q16, ratified): a
132
+ curated write-verb table covers ~93% of command nodes and is near-immune to
133
+ version drift (~0.07% of nodes per minor version), but ~6–7% of nodes carry a
134
+ write-shaped verb *outside* any small table (`reboot`, `format`, `delete`,
135
+ `reset-configuration`, …), so a two-valued field would have to call
136
+ `/disk format-drive` a non-write. The field therefore **fails closed to
137
+ `unknown`** for any verb it cannot confirm, with a **hard requirement of zero
138
+ false negatives on statically obvious writes** and `unknown` reported
139
+ separately from `false`. Dynamic execution (`:parse`, stored scripts,
140
+ variable/function invocation) is `unknown` by construction.
141
+ - **Heuristics owned and labeled.** Offline conclusions are canonicalizer
142
+ facts (provenance `canonicalizer`, basis `heuristic`/`derived`) — never
143
+ presented as device or schema truth.
144
+ - **Fail-closed with an `ambiguous` verdict.** Schema-free analysis has a hard
145
+ floor: a statement that is *nothing but a path* (`/ip/route` is a directory,
146
+ `/system/reboot` is a command, and the text is identical) cannot be resolved
147
+ by a text rule alone. The canonicalizer must abstain wherever nothing but the
148
+ text is available to decide — a distinct **`ambiguous`** verdict, separate
149
+ from "unresolved" — rather than guess. Since #210 the baked container floor
150
+ answers this one pair where it can confirm the path, and since #228 the
151
+ published command axis answers it from the other side, so the requirement
152
+ binds on everything *neither* table carries — still most of it; the two
153
+ paragraphs below that scope it are normative, not a footnote. Four
154
+ ratification questions reached this floor independently (Q6 verb/menu
155
+ boundary, Q3 absolute inner paths, Q13 `variable-undefined`, Q14 bare-word
156
+ recovery), so it is a **structural property of schema-free canonicalization**,
157
+ not a per-case wart; the fail-closed rule is promoted to the constitution's
158
+ canonicalizer-ownership section. The measured cost is small: the shipped
159
+ no-schema verb/menu rule scores **99.9%
160
+ precision on decided at ~3% abstention**, and a static snapshot would recover
161
+ only ~2.3pp of that abstention while churning ~9% of tree nodes per minor
162
+ version — which is why **decision 3 (no offline schema snapshot) ratifies
163
+ unchanged**.
164
+
165
+ The *container* half of that pair is since decidable. `src/explain/menus.ts`
166
+ (#207) bakes only the menu-vs-command node type — not a schema — and it is
167
+ version-less because the pinned trees show no `dir`↔`cmd` flip across
168
+ 7.10.2 → 7.24rc2 or across architectures. So `/ip/route` now resolves as
169
+ `navigation` (#210). The floor is unmoved: the table is deliberately
170
+ incomplete, absence from it decides nothing, and every rule above it still
171
+ refuses rather than guesses.
172
+
173
+ The *command* half is what #228 addressed, by adding a second first-order
174
+ source rather than a schema. MikroTik's CLI Reference is generated from the
175
+ definition structs, and measured against the same four pinned trees it has
176
+ **zero navigation-vs-command contradictions** across 968 exactly-matching
177
+ paths, three RouterOS versions and two architectures; all but one of the 102
178
+ published paths absent from every tree carry a `package`/`conditions`/`syscap`
179
+ gate — their own or an ancestor's — that predicts the absence.
180
+ `src/explain/catalog.ts` unions the two with per-entry provenance, which is
181
+ what keeps `menus.ts`'s device-confirmed floor intact rather than diluting it.
182
+ The publication is still not device truth, but as of #285 it is at least
183
+ spelled like the CLI: MikroTik reshaped it from module pages into per-command
184
+ leaf pages whose slug *is* the CLI path, so the definition-module spellings
185
+ (`caps-man/acl/access-list`) are gone and the alias allowlist that recovered
186
+ them is empty — kept, and kept asserted, to refuse the next one loudly. So
187
+ `/system/reboot` now resolves as a `command` at `/system`, and the floor is
188
+ again unmoved — the residual is a path in *neither* table, such as
189
+ `/disk/format-drive`, which 7.23.2 spells `/disk format` and MikroTik does not
190
+ publish at all.
191
+
192
+ Discovery is the union of MikroTik's two published inventories, not the
193
+ sitemap alone. A *branching* menu is served as a trailing-slash category URL
194
+ with no `.md` of its own, while that menu's own entry is published at
195
+ `<dir>/<basename>.md` and listed only in `llms.txt` — so sitemap-only
196
+ discovery dropped 256 of 1,070 pages, silently and field-heavily. Generation
197
+ asserts that every category dir still contributes its leaf, so the next
198
+ inventory reshape fails loudly instead of shrinking the table (#285,
199
+ tikoci/rosetta#137).
200
+
201
+ There are real unknowns in how far offline parsing can go (expression
202
+ grammar, scope fidelity vs `:parse`, `[]`-nesting corner cases). Grounding
203
+ those unknowns was **phase 0** (#185); its findings are folded in throughout
204
+ this spec and summarized in [Phase-0 ratification](#phase-0-ratification-185).
205
+
206
+ ## Three questions, one pipeline
207
+
208
+ An agent (the primary caller) asks `explain` three kinds of question before
209
+ touching `execute` / `api` / `retrieve`:
210
+
211
+ 1. **Check** — *"is this command right, and how does it run?"*
212
+ `/ip/route add dst-address=10.9.0.0/16 blackhole=yes` → canonical
213
+ `{path, verb, args, mode, writeShaped}`, spans, diagnostics, transport
214
+ classification (+ `curl`/runner rendering on request).
215
+ 2. **Assist** — *"what can come next?"*
216
+ `/ip/firewall/filter add ch` + cursor → completion candidates with
217
+ provenance, so the caller composes a correct command instead of guessing
218
+ and retrying.
219
+ 3. **Describe** — *"what exists at this path?"* (live)
220
+ A broad query returns broader, **bounded** results that invite drill-down:
221
+ - `/ip/address` → the verbs available there (with arg counts or a hint to
222
+ name a verb for its args);
223
+ - `/ip/address/set` → the settable arguments;
224
+ - `/ip/address/print` → special-cased to the `.proplist` value set, i.e.
225
+ what the output *can* contain. **The probe recipe is grounded** (Q10,
226
+ version-flat 7.23.2/7.24rc2): `completion input="<menu>/print proplist="`
227
+ with rows filtered `show === "true"` yields the property-name set (a
228
+ superset of the keys a `GET` returns). Two device traps are pinned:
229
+ (1) the console word is the dot-free `proplist=` — the REST body key
230
+ `.proplist` returns generic value metadata, not the set; and (2) the rows
231
+ are selected by `show=true`, not by `style` (whose value here is `none`,
232
+ not `arg`). The `child`/`syntax` fallback is needed only for singleton
233
+ settings menus that expose no `.proplist`.
234
+
235
+ Positions matter more than tokens: the same word can be a verb or an argument
236
+ (`/ip/address comment …` is the `comment` *verb*; `/ip/address/add comment=…`
237
+ is the `comment` *argument* of `add`), and a probe at a value position (input
238
+ ending in `=`) asks for value candidates or type info rather than names. The
239
+ canonicalizer resolves the role offline where it can and labels the basis;
240
+ live completion is the authority.
241
+
242
+ Offline, `explain` answers with canonicalizer facts only (structure, gate,
243
+ transport shape). Enumerating what a device accepts — verbs, args, enums,
244
+ `.proplist` sets — is **live** evidence; a no-router run that would benefit says so
245
+ with a tip ("richer data is available if you name a router").
246
+
247
+ ## Evidence modes
248
+
249
+ | Mode | Trigger | Sources | Provenance label |
250
+ | ---- | ------- | ------- | ---------------- |
251
+ | **Offline** (default) | no `<router>` given | the centrs canonicalizer only: segmentation, AST-like structure, path/verb/args split, script-vs-structured, write-shape, transport classification, `curl`/runner rendering | `canonicalizer` (basis `heuristic`/`derived`) |
252
+ | **Live** | `<router>` given | `request=highlight` (byte-classified spans, first hard error), `:parse` (structure, error line/column), `request=completion` / `syntax` / `child` (candidates, structured help, children/args/`.proplist` sets) | `live-inspect`, stamped with the device's version |
253
+
254
+ There is **no static schema snapshot** (decision, 2026-07-19, amended 2026-08-07
255
+ by #228): offline mode makes no call at analysis time — not to a router, not to
256
+ rosetta, not to the network — and ships no argument names, types, enums,
257
+ per-menu verb lists or `.proplist`. Version and schema questions are steered to
258
+ a live router (or to rosetta, by tip). Offline diagnostics are structural
259
+ ("this does not parse", "this is script-shaped"), never claims about what a
260
+ command accepts.
261
+
262
+ Offline mode does ship two generated **structure** tables, baked at build time
263
+ from pinned sources and read as ordinary closed lists. They may say whether a
264
+ path is navigation or a command; they never describe what a command accepts.
265
+ Absence from either abstains and never rejects.
266
+
267
+ - `src/explain/menus.ts` (#207) — container paths from pinned restraml
268
+ `/console/inspect` trees. **Read today** to confirm navigation, on its own by
269
+ `write.ts` and `symbols.ts` and unioned by `is-known-menu.ts` elsewhere.
270
+ - `src/explain/catalog.ts` (#228) — path → kind, per-entry provenance and
271
+ MikroTik's published applicability gate, unioned from those same trees and
272
+ CLI Reference. Its **command axis is read today** by `pathresolve.ts` (R12),
273
+ `verbsplit.ts` and `write.ts`. Since #235 its **menu/settings kind is read
274
+ too**, unioned with `menus.ts` behind one `isKnownMenuPath` helper
275
+ (`src/explain/is-known-menu.ts`): it confirms a relative bare path once the
276
+ inherited context is applied (`filter` or `firewall filter` under `/ip`), and
277
+ it is the same union `verbsplit.ts` consults for an absolute bare path, which
278
+ now reads `/interface/ethernet/poe` as navigation instead of `ambiguous`. An
279
+ unknown joined path abstains and poisons following relative statements. A gate
280
+ never decides anything offline — it explains why a published path may be
281
+ missing from a given router, and no router was consulted to build the table.
282
+
283
+ `write.ts` deliberately stays on `menus.ts` alone, and that asymmetry is the
284
+ safety rule rather than an oversight: a published command misread as a menu
285
+ would drop a WRITE as navigation, so a `published`-only entry is decisive for
286
+ `command` and only a tie-breaker for `menu`. The #235 union is safe where it is
287
+ read because both consumers there answer "where does the context move?", and a
288
+ wrong menu costs a path, not a missed write.
289
+
290
+ A live target arrives through the **same resolver as every other command**
291
+ (CDB name/MAC/group keys, `--quickchr`, future TikTOML — #134/#174): a
292
+ target-aware `explain` gets provider targets for free and adds no bespoke
293
+ resolution path. Fan-out does not apply — `explain` takes at most one router
294
+ and rejects selectors with `usage/fanout-not-supported`, like `terminal`.
295
+
296
+ Implementation-normative safety rules (evidence in the skill/LSP references):
297
+ per-probe timeouts always; skip scripting-keyword paths for `syntax`/
298
+ `completion` on versions **before 7.21.4** — the REST-server deadlock is
299
+ measured through 7.20.8 and 7.21.4 is the first version proven fixed
300
+ (SUP-127641), so the conservative skip covers the untested gap between;
301
+ route input past the 32,767-byte highlight cap to `:parse` or report
302
+ truncation — never analyze a silent prefix; offsets are **UTF-8 byte** offsets
303
+ over ASCII-normalized input and the normalization is recorded (the exact
304
+ normalization rule is pinned under [Result shape](#result-shape-sketch), Q15);
305
+ `[]`, timeout, and transport failure stay distinct outcomes.
306
+
307
+ The **native-api `:parse` readout is grounded, and the cell is *not* degraded**
308
+ (Q11, version-flat 7.23.2/7.24rc2): `:put [:parse "<input>"]` invoked over the
309
+ native API's `/execute` **with `as-string=""`** returns the parser text —
310
+ including the `(line N column M)` diagnostic on a syntax error — byte-identical
311
+ to what a console transport prints. The opaque `*NN` value seen otherwise is the
312
+ **no-`as-string` job handle**, not a `:parse` limitation; the readout rule is
313
+ "pass `as-string` and interpret `ret`". The existing execute gate already passes
314
+ `as-string` but currently discards `ret`; phase 2 must extract/fix that shared
315
+ readout rather than copy the omission. `/console/inspect`
316
+ (highlight/syntax/completion) works over the native API's `talk` framing too. So
317
+ the line/column diagnostic is available on both live cells — the earlier caveat
318
+ that only a console transport surfaces the parser text is retired.
319
+
320
+ ## Surface (Option A — decided)
321
+
322
+ ```bash
323
+ centrs explain '<input>' [flags] # offline
324
+ centrs explain <router> '<input>' [flags] # live — target-first
325
+ ```
326
+
327
+ `<input>` is a command, a script fragment (`--file` / stdin for scripts), or a
328
+ bare menu path. The positional grammar stays **target-first like every other
329
+ router-taking command** (amended in PR review — the shared resolver/selection
330
+ helpers assume it): one positional means offline and it *is* the input; two
331
+ positionals mean live with the router first; `--` is accepted before the
332
+ input; **`--file` replaces the input positional in either form**, so with it
333
+ every positional is a target. Adding a
334
+ router therefore never reinterprets a previously valid offline invocation.
335
+
336
+ **Ambient stdin is read only when no positional could be the input** — that is,
337
+ the offline form with nothing else to analyze. A positional always wins, and
338
+ when fd 0 is redirected anyway the result carries a
339
+ [`usage/stdin-ignored`](../../docs/errors/usage/stdin-ignored.md) warning rather
340
+ than quietly dropping the pipe. `--file -` names stdin explicitly; it is the
341
+ offline fix today, since the live form it also spells
342
+ (`… | centrs explain edge1 --file -`) is parsed and then refused with
343
+ `usage/not-implemented` until phase 2. The narrower rule is
344
+ deliberate and measured: `runCli` is called in-process by tests, so reading fd 0
345
+ on any path a positional invocation reaches consumes the invoking shell's
346
+ stdin — it fed those bytes to the analyzer and broke 21 unrelated tests once,
347
+ then passed on the next run because the read had drained the fd. Detecting the
348
+ collision is a stat on fd 0, never a read, which is why it is reported instead
349
+ of resolved.
350
+ One verb serves all three intents; the accepted risk is that
351
+ the refined "broad query → broader results" scheme adds complexity to one
352
+ surface rather than splitting into sub-verbs. (Option B — sub-verbs
353
+ `explain check|complete|path` — and Option C — folding into the runners — were
354
+ considered and set aside; C is rejected outright: the bench grounds a
355
+ *separable* cheap tier, offline explain has no runner to ride, and MCP already
356
+ serves `centrs_explain` with no CDB.)
357
+
358
+ **Smart sizing, not depth knobs.** There is no `--depth`. `explain` returns a
359
+ *reasonable* amount by default: when more detail is cheap it is included; when
360
+ a subtree (paths × verbs × args) is large, it returns **counts plus a warning
361
+ that the tree was truncated**, with hints for the narrower query that expands
362
+ the interesting part. A single override flag — working name `--full`, final
363
+ name TBD — lifts the size limits regardless of result size.
364
+
365
+ | Flag | Adds | Notes |
366
+ | ---- | ---- | ----- |
367
+ | *(base)* | canonical structure, spans, diagnostics, transport classification | offline: pure canonicalizer; live: + highlight + `:parse` |
368
+ | `--complete [--cursor <byte>]` | continuation candidates at the cursor (default: end of input) | live evidence; offline emits the live-target tip |
369
+ | `--schema` | path enumeration: verbs, args, types, enums, `.proplist` | live evidence; offline emits the live-target tip |
370
+ | `--curl` | rendered REST call (`curl …`) for `api-candidate` statements covered by a tested mapping rule, plus the equivalent `centrs api`/`centrs execute` invocation | offline-capable; placeholder host + elided credentials when no router is given; `unknown` classifications render no curl |
371
+ | `--full` *(name TBD)* | lift smart-sizing limits | — |
372
+
373
+ Rendering stays separate: `--format`/`--json` remain the settings-vocabulary
374
+ output switch. The longer-term wish behind "format levels" — a colorized
375
+ breakdown of the command (e.g. unset attributes in grey) — is a **rendering**
376
+ concern for the CLI/TUI layer over the same data, not a richness level.
377
+
378
+ Exit codes follow the `check` pattern: `0` clean, `2` when diagnostics meet
379
+ `--fail-on <error|warning|never>` (default `error`), `1` on command failure.
380
+
381
+ ## Result shape (sketch)
382
+
383
+ Standard envelope (constitution: result envelope); `data` sketch:
384
+
385
+ ```json
386
+ {
387
+ "input": {
388
+ "bytes": 51,
389
+ "normalized": false,
390
+ "truncated": false,
391
+ "positionMap": [ { "analyzed": { "start": 0, "end": 51 }, "originalUtf16": { "start": 0, "end": 51 } } ]
392
+ },
393
+ "verdict": "pass",
394
+ "canonical": { "path": "/ip/route", "verb": "add", "args": { "dst-address": "10.9.0.0/16", "blackhole": "yes" }, "mode": "structured", "writeShaped": true },
395
+ "structure": {
396
+ "statementCount": 1,
397
+ "statements": [
398
+ {
399
+ "span": { "start": 0, "end": 51 },
400
+ "resolution": "resolved",
401
+ "kind": "command",
402
+ "command": { "path": "/ip/route", "verb": "add", "args": { "dst-address": "10.9.0.0/16", "blackhole": "yes" } },
403
+ "transport": { "classification": "api-candidate", "rest": { "method": "PUT", "path": "/rest/ip/route" }, "curl": "curl -u … https://<router>/rest/ip/route …", "centrs": "centrs api <router> …" },
404
+ "ev": "e0"
405
+ }
406
+ ],
407
+ "blocks": [],
408
+ "containsWrite": true,
409
+ "subcommands": []
410
+ },
411
+ "symbols": { "occurrences": [] },
412
+ "values": { "occurrences": [ { "id": "v0", "span": { "start": 26, "end": 37 }, "tokenSpan": { "start": 14, "end": 37 }, "kind": "attribute", "name": "dst-address", "quoted": false, "facts": { "shapeHints": { "values": ["ip-prefix"], "ev": "e9" } } } ] },
413
+ "spans": [ { "start": 0, "end": 9, "class": "path", "ev": "e0" } ],
414
+ "diagnostics": [],
415
+ "evidence": [
416
+ { "id": "e0", "source": "canonicalizer", "probe": "resolveVerbs", "basis": "heuristic", "outcome": "ok" },
417
+ { "id": "e9", "source": "canonicalizer", "probe": "valueShapeHints", "basis": "heuristic", "outcome": "ok" }
418
+ ],
419
+ "runtimeAcceptance": "not-proven"
420
+ }
421
+ ```
422
+
423
+ - **The two verdicts are separate.** `data.verdict` is the maximum diagnostic
424
+ severity (`pass` / `warn` / `fail`) and drives `--fail-on` only. Each entry in
425
+ `structure.statements[]` has a canonicalizer `resolution` of `resolved`,
426
+ `ambiguous`, or `unknown`. `ambiguous` means the input has multiple valid
427
+ schema-free readings (for example a bare path may be a menu or a no-argument
428
+ command); `unknown` means analysis cannot safely recover a reading. Neither is
429
+ silently collapsed to a diagnostic severity or to a guessed command. A
430
+ resolved statement also carries `kind: "menu" | "command"`, and the three
431
+ shapes are a **discriminated union**, not four independent optional fields:
432
+ a menu carries `command: { path }` and no verb (navigation names a menu and
433
+ nothing else), a command carries `command: { path, verb }`, and a refusal
434
+ carries `unresolved` and no `command`. Narrowing on `resolution`/`kind` yields
435
+ the fields that exist, which is what the LSP and MCP consumers need from the
436
+ exported types. The library reports the two kinds as *separate
437
+ resolutions* — `src/explain/verbsplit.ts` returns `navigation` (with
438
+ `kind: "menu"`) beside `resolved` (with `kind: "command"`), so `resolved`
439
+ keeps meaning "a verb was decided" for its callers. Phase 1 folds `navigation`
440
+ into this envelope's `resolved` + `kind: "menu"`; the envelope vocabulary here
441
+ is unchanged.
442
+ - **Whole-input gate and per-statement analysis stay distinct.** `canonical`
443
+ reproduces the existing execute gate's whole-input fields unchanged, including
444
+ its `mode` and `writeShaped` decision. `structure.statements[]` is the
445
+ addressable rich surface for script-scale input: every statement carries its
446
+ source span, resolution, resolved command when available, transport
447
+ classification, and evidence id. This is where multi-statement scripts and
448
+ partial recovery live; a single top-level transport object cannot represent
449
+ them.
450
+ - **Every derived fact keeps provenance via `evidence[]`** — facts reference
451
+ a stable evidence id (`ev`), and the evidence entry carries source
452
+ (`canonicalizer` vs `live-inspect`), probe, basis (direct response vs
453
+ derived vs heuristic), outcome (`ok`/`empty`/`timeout`/`transport-error`),
454
+ and the RouterOS version stamp. A single global version field is *not*
455
+ enough: facts in one result can come from different probes with partial
456
+ failures. The `RouterOsSyntaxEvidence` contract in lsp-routeros-ts
457
+ `docs/syntax-inspection-map.md` is the adaptation source. Resolved target
458
+ identity lives in the envelope's `meta.target` (constitution) — `data`
459
+ grows no competing identity surface.
460
+ - **Envelope semantics mirror `check`**: an analysis that ran is `ok: true`
461
+ even when the input is riddled with errors — the diagnostics *are* the
462
+ data, summarized by `data.verdict` (max severity after analysis). Exit
463
+ codes derive from the verdict vs `--fail-on`; `ok: false` is reserved for
464
+ genuine command failure (unresolvable target, usage error, probe transport
465
+ failure). This lets the library and MCP reproduce the same decision without
466
+ a process exit code.
467
+ - **Coordinates are contracted, not implied**: probe offsets are UTF-8 byte
468
+ offsets over the *analyzed* (ASCII-normalized) input with `end` exclusive,
469
+ and `input.positionMap[]` maps half-open analyzed-byte runs to half-open
470
+ original UTF-16 runs. Identity runs are retained, so consumers never need a
471
+ separate coordinate contract for normalized and unnormalized input.
472
+ The library surface additionally provides original-document positions
473
+ (line + UTF-16 character) for LSP consumers; `--cursor <byte>` stays a
474
+ wire-level CLI convenience. **The ASCII-normalization is byte-count-preserving**
475
+ (Q15, ratified): each non-ASCII byte is replaced by a single placeholder byte
476
+ one-for-one (a multi-byte character becomes N placeholder bytes, never
477
+ collapsed to one), so an analyzed byte offset **equals** the device's UTF-8 /
478
+ `highlight` byte offset identically and offline spans and highlight spans share
479
+ one coordinate system with no fixup. A reference mapper validated the required
480
+ invariants at 100% over ASCII / BMP / astral-surrogate / combining-mark / tab /
481
+ LF-CRLF / lone-surrogate fixtures: every span in bounds; original↔analyzed
482
+ round-trips at boundaries; slicing the original range yields the intended
483
+ token; `end === len` is the legal end-of-input cursor; a cursor interior to a
484
+ multi-byte character snaps to that character's boundary. centrs does not
485
+ NFC-fold — a combining mark is its own position — and `\r\n` counts as one line
486
+ advance while a lone `\r` is not a line break. The production parser runs on the
487
+ analyzed string directly (it is pure ASCII, so its JS index *is* the byte
488
+ offset) and this mapper converts to original line/UTF-16 positions.
489
+ - **Spans use a centrs-owned vocabulary** (decision): raw RouterOS highlight
490
+ classes are not the default surface. The centrs vocabulary must be at least
491
+ as rich as the RouterOS token classes, and centrs maintains the mapping —
492
+ including color mappings derived from the original RouterOS highlight
493
+ colors — so an LSP consuming centrs can reproduce the current RouterOS
494
+ color scheme faithfully. The indirection decouples consumers from
495
+ cross-version drift in the raw classes; raw classes stay available in
496
+ provenance/debug output.
497
+ - Diagnostics are slash-namespaced (`explain/<source>/<slug>`), carry byte
498
+ spans (or line/column from `:parse`), and follow the standard error-model
499
+ severity channels. **Malformed input carries a defect *region*** (Q14): each
500
+ diagnostic points at the byte span of its defect, and the detectable classes
501
+ are {unbalanced delimiter, unterminated string, invalid escape, invalid sigil,
502
+ invalid unquoted hash, BOM, non-ASCII, over-depth nesting}. **Two classes named
503
+ in the phase-0 draft
504
+ of this list are deliberately deferred** (maintainer decision, #192), because
505
+ each would require offline to assert something it cannot prove:
506
+ - **truncation** — offline cannot distinguish a truncated *complete-looking*
507
+ token from a finished one without a schema, as the `--complete` paragraph
508
+ below already states. What is detectable is the *continuation state*
509
+ (trailing `=`, open delimiter), which is a `--complete` concern reported
510
+ against a live target, not a defect region. Emitting it as a defect would be
511
+ the confident-claim-on-ambiguous-input posture Q14 forbids.
512
+ - **stray mid-token delimiter** — plausible but ungrounded. It needs a probe
513
+ matrix (spellings × positions × accepted-form neighbors) before a detector
514
+ is worth having; the #201 round is the standing evidence that a lexical rule
515
+ curated from intuition produces defects the corpus cannot reach.
516
+
517
+ Both remain candidates; re-opening either means grounding it first and
518
+ amending this list. `BOM` and `non-ASCII` are **positional facts, never
519
+ errors** — they record where the byte-count-preserving normalization stood in
520
+ for unreadable bytes, and a renderer must not give them error severity (a
521
+ non-ASCII value such as `name="router-🚀"` is a legal command, and non-ASCII
522
+ occurs in ~12% of the phase-0 corpus). Two hard
523
+ recovery rules follow from the phase-0 mutation suite, both **fail-closed**:
524
+ (a) corruption may only degrade a command to `ambiguous`/`unknown`, **never
525
+ invent a confident one** after a defect region — the same fail-closed floor as
526
+ the canonicalizer's `ambiguous` verdict, and the direct fix for the measured
527
+ case where a stray `;` inside a value splits off a garbage fragment that
528
+ offline would otherwise resolve to a bogus path; (b) a **bare-word-headed
529
+ statement** resolved against document context degrades to `unknown` when the
530
+ context is uncertain (an upstream defect can poison it), because a bare word is
531
+ Q4's cascade residual. Deeply nested delimiters emit an over-depth diagnostic
532
+ rather than recursing (Q17 measured stack-overflow and O(n²) blow-up in a naive
533
+ recursive walker; the product parser bounds depth and scans single-pass).
534
+ Offline `--complete` detects trailing-`=` and open-delimiter continuation but
535
+ cannot distinguish a truncated *complete-looking* token from a finished one
536
+ without a schema — so it surfaces the detectable continuation state and defers
537
+ candidate generation to a live target.
538
+ - `runtimeAcceptance: "not-proven"` is structural, not boilerplate: it is the
539
+ inspect-vs-runtime gap made machine-readable.
540
+ - Live-state token classes (disabled/dynamic/inactive object references)
541
+ surface as facts, not errors; severity policy belongs to the caller.
542
+
543
+ ### What phase 1 offline actually emits (#202a)
544
+
545
+ The sketch above is the whole shape, and `examples.md` describes the FINISHED
546
+ command — both stay as they are. This table says which parts `src/explain.ts`
547
+ ships today, so a reader can tell a decision from an oversight. Every gap closes
548
+ by ADDING a field, never by changing one, which is why the examples do not need
549
+ to move: an example asserting a field this phase omits is simply not green yet,
550
+ and its phase is named below.
551
+
552
+ | Part | Phase 1 offline |
553
+ | ---- | --------------- |
554
+ | `input`, `verdict`, `canonical`, `structure`, `diagnostics`, `runtimeAcceptance` | complete |
555
+ | `evidence[]` | the **offline subset**: `source` is always `canonicalizer` and there is no RouterOS version stamp |
556
+ | `structure.statements[].command` | `path` + `verb`, plus **`args` where the argument list was read** (#202c-1); the ordered token list is `statements[].arguments.tokens` |
557
+ | `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 |
558
+ | `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) |
559
+ | `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` |
560
+ | `spans` | comment runs and resolved variable classes only; **no value shape or type** |
561
+ | `schema`, `completion` | absent — live evidence, phase 2 |
562
+
563
+ - **Per-statement arguments are lexed, and the list is all-or-nothing** (#202c-1,
564
+ `src/explain/args.ts`). The ordered token list with spans is
565
+ `statements[].arguments.tokens` (the phase-0 normal form); `command.args` is
566
+ its derived object view, present only when the list was read. Two different
567
+ outcomes, which a reader must not conflate:
568
+ - **The whole list is refused** — `arguments.read` is `false` with a reason,
569
+ and `command.args` is absent — when a token cannot be delimited or named at
570
+ all: a `[…]`/`(…)`/`$x`/`{…}` value, an escape this phase does not decode, a
571
+ left-hand side that is not a RouterOS name, a token split by a continuation.
572
+ Never partially read, because a dropped argument silently changes what a
573
+ rendered `curl` DOES. Two refusals are not lexical at all, but
574
+ places where centrs's two readers disagree about an **unquoted** value: a
575
+ `'`, which RouterOS treats as an ordinary character and the locked execute
576
+ gate treats as a quote (`comment=it's` is `it's` here and `its` to the
577
+ gate); and a `\f`/`\v`, which the gate splits tokens on (JavaScript `\s`)
578
+ and every explain module does not (ASCII whitespace). The device-correct
579
+ reading is the analysis's in both, but the gate cannot be corrected — so
580
+ phase 1 publishes neither rather than putting two confident values in one
581
+ result. Inside a `"…"` run the two agree and nothing is refused. The
582
+ advisory value-anchor view is intentionally wider only for exact array
583
+ literals: a non-empty `{…}` value or a parenthesized run with a depth-zero
584
+ comma is locatable and hints `array`, while the strict REST view continues
585
+ to refuse the whole list.
586
+ - **A token is read but carries no `value`** — the list still reads. The token
587
+ is delimited and classified; only its literal value is unknowable, as for
588
+ the positional in `:log info "result: $[…]"`. **`value` absent means there
589
+ is no literal value**, whatever the token's kind, so a consumer rendering a
590
+ runnable command reads `value`, never the source `text`, and treats absence
591
+ as not-renderable.
592
+
593
+ Measured on the frozen corpus: 40.0% of CRUD-verb commands read, 0 arguments
594
+ dropped against the IL oracle, and 0 contradictions with `canonical.args`
595
+ where both decided. That last one holds *because* of the fail-closed rules
596
+ above, not by accident — a trailing `;`, a `'`, and `\f`/`\v` each
597
+ contradicted until review found them. The `;` case is enforced at the
598
+ composition boundary rather than in the lexer, because segmentation strips the
599
+ delimiter before the lexer ever sees it: where the gate read the whole input
600
+ as `structured` and the single statement's reading differs, the analysis
601
+ abstains.
602
+
603
+ What offline still cannot do is NAME a positional operand — RouterOS binds
604
+ `:log info "x"` to `message=x` from its schema, and offline reports the
605
+ located positional instead of inventing the name.
606
+ - **Transport is classified per resolved command and fails closed.** Only the
607
+ Q8 rules exercised on CHR 7.23.2 and 7.24rc2 become `api-candidate`;
608
+ script-shaped inputs become `execute`, and untested or ambiguous mappings
609
+ become `unknown` with no runnable REST rendering. Four rules are keyed on a
610
+ literal verb (`add`/`get`/`set`/`remove`) and `print` has its three shapes;
611
+ the fifth was recorded as the RULE `action → POST /rest/<path>/<command>`, so
612
+ every other verb rides it. That rule maps a URL and does **not** claim the
613
+ verb exists — a `catalog.ts` miss narrows the basis string, never the
614
+ classification, because "a MISS says nothing" is that table's contract and
615
+ existence is the phase-2 `/console/inspect` answer. An action operand offline
616
+ cannot NAME (`enable *A`, `monitor 0`) fails closed, since RouterOS binds it
617
+ from per-menu schema. Curl is opt-in and uses placeholders offline. Menus and
618
+ refused readings have no transport because there is no command to classify.
619
+ - **`spans` carries what offline can prove.** Comment runs, and the variable
620
+ classes Q13 scored at 100% precision on resolved bindings; an abstention is
621
+ omitted rather than rendered as a guess. The Q12 vocabulary over
622
+ path/verb/argument bytes wants device `highlight` as its oracle. A subset is
623
+ not a claim that the vocabulary is closed.
624
+ - **`symbols` is the semantic Q13 projection plus flow-sensitive value refs
625
+ (#239 S2).** Each occurrence carries its source name/span, resolved class
626
+ (or `null` when offline abstains), role (`declaration`, `binding`,
627
+ `assignment`, `reference`, or filter `field`), and result-local binding
628
+ identities. Those identities distinguish shadowed names and connect `:set`/
629
+ reference sites to their declaration; they are stable only within one result.
630
+ When `:onerror` establishes two bindings, the list is ordered outer-scope
631
+ binding first, statement-scope binding second. A declaration/binding/
632
+ assignment with a literal RHS in the same statement carries `valueId`
633
+ pointing at its `values.occurrences[]` row; a reference carries
634
+ `reachingValueIds` (the set of literal ids that may reach it) and
635
+ `reachingUnknown` when the set is incomplete. Linear code is last-write-wins;
636
+ a branch merge is a set; a loop merge or a non-literal RHS (`$x`,
637
+ `[find …]`, an expression) is `unknown` — no RHS evaluation, so a
638
+ `reachingUnknown` reference must not be read as a type assertion. The three
639
+ value facts themselves stay on `values.occurrences[].facts`; symbols only
640
+ *refer* to them. A hyphen always terminates a bare `$name`: `$set-dns` reads
641
+ `$set`, while `$"set-dns"` is the spelling that reaches a hyphenated
642
+ declaration. Braced forms such as `${set-dns}` and `${"set-dns"}` are
643
+ rejected by RouterOS at `{` and resolve no reference. In an expression the
644
+ remaining `-dns` is scanned as subtraction plus its right operand; inside a
645
+ string it is literal text.
646
+ - **Evidence is offline-shaped, not the whole contract.** The bullet above says
647
+ an evidence entry carries `source` (`canonicalizer` vs `live-inspect`) and a
648
+ RouterOS version stamp; phase 1 emits neither the live source nor the stamp,
649
+ because no probe ran and there is no device to stamp. `ExplainEvidence` is
650
+ typed to what offline produces rather than pre-declaring variants nothing can
651
+ emit, so phase 2 widens the union and adds the stamp — an addition, not a
652
+ change to what a caller reads today.
653
+ - **Value facts are three axes, never one `type` string (#225).** RouterOS
654
+ types values at parse time while `highlight` classes every value byte `none`,
655
+ so type evidence is not a subset of Q12 spans. Each result-local
656
+ `values.occurrences[]` row owns one value span and a `facts` object:
657
+ 1. `shapeHints: { values, ev }` is offline and heuristic — a list, because the
658
+ axis admits overlap;
659
+ 2. `observedType?: { value, ev }` is what a live parser/IL reading supports;
660
+ 3. `schemaType?: { value, ev }` is the argument's declared live-schema type.
661
+
662
+ The stable/testing CHR matrix fixes the offline lexicon: `num`, `ip`,
663
+ `ip-prefix`, `ip6`, `ip6-prefix`, `id`, `time`, `array`, `mac`, `bool`, and
664
+ `str` — `op` is deliberately not a member: a deferred `(> …)` abstains
665
+ offline and is reported only as a live `observedType` (#288); quoted literals
666
+ hint only `str`, while malformed and out-of-range address-like controls
667
+ abstain even in named attributes. Most vocabulary borrows RouterOS scripting
668
+ type names (with the explicit `macAddr` schema exception below), so a hint
669
+ must never spell a scripting type the device contradicts: `num` is
670
+ **integer-only**, since RouterOS numbers are integers and a dotted decimal is
671
+ an IPv4 shortcut — bare `2.2` is observed as `ip` (`2.0.0.2`), so it hints
672
+ `ip` alone. On that grounded lexicon each spelling reaches at most one shape.
673
+ Colon-form time accepts RouterOS's displayed H:M / H:M:S spelling plus
674
+ week/day prefixes and fractional tails; `00:11:22` is therefore `time`, not a
675
+ short MAC. `mac` deliberately names the CLI Reference `macAddr` schema
676
+ spelling rather than a scripting type: a full six-octet MAC hints `mac` while
677
+ scalar `:typeof` reports `str`. `*1` hints the documented `id` type. A single
678
+ non-time colon is not an address attempt — `comment=foo:bar` hints `str` —
679
+ while malformed multi-colon runs stay fail-closed.
680
+
681
+ Arrays are the one structured literal admitted by offline hint anchoring.
682
+ Non-empty `{…}` values and parenthesized expressions with a depth-zero comma
683
+ hint `array`; grouping `(1)`, empty groups, and scope braces do not. The
684
+ strict REST argument reader still refuses every structured value. RouterOS
685
+ has no empty-array literal (`{}` is a syntax error); `[:toarray ""]` produces
686
+ an empty array, but remains an expression and therefore an offline
687
+ abstention. Indexing that empty array produces `nothing`. `.` remains an
688
+ expression operator rather than part of a literal: it produces `str` for
689
+ `1 . 2`, but distributes over arrays in either operand order, so offline
690
+ abstains on the whole concat expression and leaves its result type to #236.
691
+ `[:parse "…"]` similarly produces the observed but undocumented `code` type,
692
+ and `[:nothing]` produces `nil`; neither has a standalone literal spelling,
693
+ so neither becomes an offline shape member. `(> …)` deferred expressions
694
+ are the same case with a different outcome: `(>[:return 1])` is `op` and
695
+ `(>{"a"=1})` is `array` at runtime, but both lower to `(> …)` with arity
696
+ 1 and identical `:parse` IL — only `:typeof` distinguishes them
697
+ (`test/fixtures/explain/operators.json` → `sweep.runtime`
698
+ `typeof-deferred`/`deferred-array-typeof` and `sweep.opAxis` `defer-*` on
699
+ 7.21.5, 7.23.3, 7.24rc4). Offline abstains on every such form; `op` lives
700
+ only as a live `observedType` (#288, a bounded #225 decision).
701
+ Shape remains non-authoritative: `100000w` is time-shaped but observed as
702
+ `str`. Argument context remains separate: on 7.23.3 and 7.24rc3, both
703
+ `/ip/address address=2.2` and firewall `src-address=2.2` canonicalize it to
704
+ `2.0.0.2`, while `comment=2.2` stays text, netwatch `interval=2.2`
705
+ normalizes to `00:00:02.200`, a boolean slot rejects it, and `:parse` still
706
+ accepts `src-address=not-an-ip`. Boolean grounding has the same separation:
707
+ bare `true`/`false` and `yes`/`no` are scalar `bool`, while their quoted forms
708
+ are `str`; a CLI `disabled=` slot accepts bare or quoted `yes`/`no` but rejects
709
+ `true`/`false`, while REST accepts JSON booleans and the string forms
710
+ `yes`/`no`/`true`/`false`. `:tobool` conversion is a separate runtime operation
711
+ and does not widen the hint. Thus a hint never validates a value or becomes a
712
+ diagnostic, and never changes `runtimeAcceptance`. Quoting likewise never
713
+ earns a wrong-type diagnostic: commands may cast quoted strings at runtime.
714
+ Offline emits only `shapeHints`; phase 2 adds the two live facts into their
715
+ existing homes when its safe IL/schema producers land.
716
+
717
+ **A located array is read member by member (#225 V1).** Each member of a
718
+ `{…}`/`(…)` literal is its own `values.occurrences[]` row with `kind:
719
+ "element"`, its own span and hints, a `parent` naming the container's
720
+ result-local id, and `name` when the brace form spells a key. The separator is
721
+ the delimiter's and not a choice: a brace splits on `;` while a depth-zero `,`
722
+ inside one builds a nested array (`{1,2}` is a ONE-member array whose member
723
+ is `(, 1 2)`), a paren splits on `,`, and `(1;2)` does not parse.
724
+
725
+ **`=` does three different things inside a literal, and a byte of whitespace
726
+ picks between them (#258).** It binds a key only in the brace form, and only
727
+ where the NAME TOUCHES the sign: `{a=1}` is the key `a`, but `{a =1}` lowers
728
+ to `(= $a 1)` and `(a=1,b=2)` is two comparisons, so keys are read in braces
729
+ only. A key whose right side is EMPTY is not an empty-valued key — the device
730
+ drops the sign and keeps the name as a POSITIONAL `str`, so `{a=}` is the
731
+ one-member array `a`, `{"a b"=}` decodes to `a b`, and `{1.1=}` is the string
732
+ `1.1` rather than the address (no member lexicon runs on a name). Everywhere
733
+ else the sign compares, which returns `bool` whatever it compares, so
734
+ `{$a=1}`, `{(a)=1}`, `{[:timestamp]=1}` and `{{1;2}=1}` are each one `bool`
735
+ member. Every key is a `str` however it is spelled: `{1=1}` keys on the string
736
+ `1`, and `{1.1=1.1}` keys on `1.1` while its VALUE goes through the address
737
+ shortcut to `1.0.0.1`.
738
+
739
+ **A member key's name is not the identifier grammar**, and was swept
740
+ character by character rather than borrowed. `.`, `-` and `/` are ordinary
741
+ name bytes anywhere in it, including alone and repeated — `{.id=1}`,
742
+ `{..id=1}`, `{.=1}`, `{-=1}`, `{--=1}`, `{/=1}`, `{a/b=1}` and `{-.1=1}` each
743
+ bind the key spelled exactly that way — while `_` does NOT, even though
744
+ `$a_b` is a good variable: `{_a=1}` and `{a_b=1}` lower to comparisons.
745
+ Neither do `: * ~ % > < + ! @ #`. `@` is stronger still — not a member
746
+ character at ANY position, so `{a@b}`, `{@a}`, `{1;a@b}` and `(a@b,2)` are
747
+ withdrawn outright, though the same bytes are fine one slot out
748
+ (`:local z a@b` parses) or inside quotes (`{"a@b"}`).
749
+
750
+ `bool` is claimed only where the `=` is the member's TOP operator, which
751
+ offline reads as a left side that is one complete operand — a name, `$name`,
752
+ or a fully-enclosing `(…)`/`[…]`/`{…}`. `{a b=1}` lowers to `( $a (= $b 1))`
753
+ and `{$a=1,2}` to `(, (= $a 1) 2)`; in both the sign is nested under an
754
+ operator whose type is its operands', so both abstain. That operand class is
755
+ **narrower than RouterOS's**, deliberately: `{a_b=1}`, `{:a=1}`, `{1+1=2}`
756
+ and `{$a/b=1}` are `bool` on the device and silent here, because widening it
757
+ means first proving the `=` stays on top for the new spelling. Operand
758
+ position is also stricter than KEY position — `{a-=1}` binds the key `a-`
759
+ while `{a- =1}` does not parse at all — so both classes end on an
760
+ alphanumeric there.
761
+
762
+ An empty side is a
763
+ syntax error in every spelling asked — `{=}`, `{=1}`, `(=1,2)`, `{$a=}`,
764
+ `{(a)=}`, `{a =}`, `{a b=}`, `{a,b=}`, `{[:len 1]=}`, `{{1;2}=}`, `{1+1=}`,
765
+ `{a<=}`, `{a==}`, `(a=,2)` — so it withdraws the literal rather than
766
+ abstaining on the member. That withdrawal needs POSITIVE evidence that the
767
+ left side is an expression (a byte no key may hold — `NOT_IN_MEMBER_NAME` in
768
+ `src/explain/args.ts`, the complement of `MEMBER_NAME`), never the key
769
+ reader's silence: a name spelling this phase does not recognize abstains
770
+ instead, so the next `.id` costs a dropped member rather than a withdrawn
771
+ literal.
772
+
773
+ **Two member faults have nothing to do with `=` and are checked before it.**
774
+ A `$` takes an alphanumeric or a quote and nothing else — `$"a b"` is a good
775
+ variable name while `$.id`, `$_a`, `$:a`, `$/a`, `$$a`, `$(x)` and a bare `$`
776
+ are syntax errors — and no run may END on a dangling operator: `{-}`, `{--}`,
777
+ `{a-}`, `{$a-}` and `{$a.}` are refused, though `{a.}`, `{.}`, `{/}` and
778
+ `{a/}` parse, because a bare name absorbs a trailing dot and a `$` name does
779
+ not. Both hold anywhere in a member, and both reach the `=` axis only through
780
+ a left operand (`{a- =1}`, `{$a-=1}`, `{$.id=1}`). The 186
781
+ rows are in
782
+ `test/fixtures/explain/values.json` → `interiorGrounding.keyBinding`, and the
783
+ value census below is unchanged by all of it — no statement in the 948-script
784
+ corpus reaches any of these branches, so the fixture is the only falsifier
785
+ they have.
786
+
787
+ **A member is not an argument value, because the device does not parse it as
788
+ one.** Inside a literal RouterOS reads an expression, so the member lexicon is
789
+ its own: a bare word is a variable reference (`{abc}` lowers to `$abc`), and
790
+ so is a full MAC (`{00:11:22:33:44:55}`) and any time literal past the signed
791
+ 64-bit nanosecond range, whose cliff sits between `15250w` and `15251w`,
792
+ `106751d` and `106752d`, `9223372036s` and `9223372037s`. `*1` does not parse
793
+ at all there, though `:local x *1` is `id`. Hexadecimal is added — `0x10` is
794
+ 16, while `0X10` is a variable — and `str`-by-fallback, `mac` and `id` are
795
+ dropped. Everything else abstains: expressions, substitutions, `$x`, and the
796
+ dotted-decimal-to-seconds fallback described below.
797
+
798
+ **A `{…}` array literal is legal only in a root scripting directive's value
799
+ slot, and only in a slot the device proved.** `/console/inspect` classes the
800
+ `{` byte `error` and `:parse` refuses the statement for
801
+ `/ip/route/add comment={1;2}`, `/ip/dns/set servers={1.1.1.1;8.8.8.8}` — a
802
+ LIST-typed attribute, which is what rules out a schema-shaped reading —
803
+ `/interface/print .proplist={name;comment}`,
804
+ `/ip/route/print where comment={1;2}`, the relative spelling
805
+ `ip route add comment={1;2}`, and `:log info message={1;2}`; while
806
+ `:local z {1;2}`, `:put {1;2}`, `:len {1;2}` and `:foreach i in={1;2}` all
807
+ parse.
808
+
809
+ Being a directive is necessary but not sufficient, and the path does not
810
+ decide: `:delay {1;2}`, `:beep {1;2}`, `:resolve {1;2}`,
811
+ `:if condition={1;2}` and `:local name={1;2}` are syntax errors at a `/` path
812
+ too. The gate is keyed on **(verb, slot)** from a device sweep of every root
813
+ builtin and every slot it completes — 222 rows in
814
+ `test/fixtures/explain/values.json` → `interiorGrounding.braceSlots`, baked
815
+ into `src/explain/brace-slots.ts` — and a slot has four outcomes, not two:
816
+
817
+ | outcome | what the slot does with `{(1,2)}` | example |
818
+ | ------- | --------------------------------- | ------- |
819
+ | `array` | evaluates it — IL `(, 1 2)` | `:local z`, `:put`, `:foreach in=`, `:for from=` |
820
+ | `code` | runs it as a program | `:onerror in=`, `:retry command=`, `:if do=` |
821
+ | `text` | keeps it verbatim as a script | `:execute script=`, `:grep script=` |
822
+ | `error` | refuses the statement | `:delay`, `:beep`, `:local name=` |
823
+
824
+ Only `array` slots are read as arrays; the other three and the 30 rows the
825
+ sweep could not ask are refused, which costs coverage and never correctness.
826
+ A positional is keyed by its index, because position decides — `:local {1;2}`
827
+ puts the literal in the NAME slot and does not parse, while `:local z {1;2}`
828
+ puts it in the VALUE slot and does. `{1;2}` alone cannot build this table:
829
+ `:local z {1;2}` and `:execute script={1;2}` both lower to `…=1;2`, so the
830
+ array test has to be a literal whose evaluated and verbatim readings differ. The `(…)` spelling is never rejected at the delimiter in any of those
831
+ positions — `.proplist=(name,comment)` still draws `bad parameter .proplist`,
832
+ but that is a name-level diagnostic and the plain `name,comment` spelling
833
+ draws it too — and a `(…)` or `[…]` around a brace restores the expression
834
+ context. The braces a
835
+ command argument does take are script bodies (`source=`, `on-event=`), and
836
+ those are not arrays either — they are refused earlier as scope blocks. A
837
+ top-level brace value in any other command argument emits
838
+ `explain/canonicalizer/invalid-command-brace` at the `{`; parentheses or a
839
+ `[…]` substitution restore the expression context where the same literal is
840
+ legal. An empty member is a
841
+ syntax error (`{;}`, `{;1}`, `{1;;2}`, `(1,)`) and withdraws the enclosing
842
+ `array` shape with it; a single trailing separator is legal in the brace form
843
+ only (`{1;}` and `{1;2;}` parse, `{1;2,}` and `{2,}` do not).
844
+
845
+ **A nested member the device rejects withdraws its container too.** A `(…)`
846
+ inside a literal is a GROUP as often as an array — `{(1)}` is the one-member
847
+ array `1` — so an empty group (`{()}`, `{a=()}`) and an empty comma member
848
+ (`{(1,)}`, `{(,)}`, `{1;(2,)}`, `(1,(2,))`) are named as faults rather than
849
+ abstained on: `:parse` rejects each of them at every nesting depth, while
850
+ `{(1)}`, `{a=(1)}` and `{1;(2)}` parse. `highlight` is not the oracle for this
851
+ family — it accepts `{1;2,}`, `{2,}` and `{(1,2),}`, all of which `:parse`
852
+ rejects. Member descent stops after eight frames, and the bound WITHDRAWS
853
+ rather than keeping an unverified shape: a fault below it still makes the
854
+ whole statement a syntax error — `:parse` rejects a `(1,)` buried nine levels
855
+ deep exactly as it rejects a shallow one — so a literal nested deeper than
856
+ eight is refused instead of called an array. The deepest member in the
857
+ 948-script corpus sits at depth 6.
858
+
859
+ **The bare comma spelling is the one place a hint is genuinely plural.**
860
+ `=1,2,3` is not a syntax error anywhere, and whether the device SPLITS it is
861
+ decided by the argument's type, which offline does not have:
862
+ `servers=1.1.1.1,8.8.8.8` lowers to `servers=1.1.1.1;8.8.8.8` and
863
+ `dst-port=80,443` to `dst-port=;80;443`, while `comment=a,b` and
864
+ `interface=ether1,ether2` keep the whole run as one string. A named attribute
865
+ therefore carries `["array", "str"]` — both readings, neither validated. In an
866
+ expression position there is no second reading (`:local x 1,2` is a
867
+ two-member `array`, and so is `a,b`), so the hint is singular there. No
868
+ members are located for a bare comma run: whether it splits at all is the
869
+ schema's answer, and a member span would be a guess. The `(1,2)` spelling,
870
+ where the delimiters prove it, is anchored and descended into as usual.
871
+
872
+ The short IPv4 spelling fills the LOW octets and every field is one octet:
873
+ `1.255` is `1.0.0.255` but `1.256` is `time` `00:00:01.256`, `1.16777215` is
874
+ `time` and not `1.255.255.255`, and `1.1.256` is plain text. Offline abstains
875
+ on the seconds fallback rather than encoding a chain that depends on a failed
876
+ address attempt.
877
+
878
+ <!-- BEGIN GENERATED value-census — regenerate with `bun run explain:value-census:readme` -->
879
+ The corpus census is re-derivable with `bun run explain:value-census` and
880
+ covers 948 source scripts. The figures below are generated from
881
+ `test/fixtures/explain/values.json` → `corpus` by
882
+ `bun run explain:value-census:readme` and gated against it by
883
+ `bun run explain:value-census:readme:check`; the fixture itself is gated
884
+ against a fresh corpus run by `bun run explain:value-census:check`. Of the
885
+ 13,168 emitted values in a statement the strict argument lexer ALSO read, 0
886
+ disagree with it on half-open byte span or decoded text, while the
887
+ prefix-safe scan retains a further 6,540 values across 569 statements whose
888
+ strict REST reading abstains. Of 19,708 emitted occurrences, 5,636 are array
889
+ members (529 keyed, 1,147 nested inside another member) and 813 are arrays;
890
+ the corpus holds no source-literal `id` example. The three structural
891
+ counters — spans addressing bytes outside their own source, members naming a
892
+ container that does not exist, members escaping the container they name —
893
+ read 0, 0 and 0, and each must stay 0.
894
+ <!-- END GENERATED value-census -->
895
+
896
+ The strict lexer remains all-or-nothing; only non-authoritative hints use the
897
+ wider view.
898
+
899
+ ### Offline comment placement (#245)
900
+
901
+ Comment recognition is contextual, not a rule that every unquoted `#` begins a
902
+ comment. The shared offline walkers follow this table, grounded with
903
+ `/console/inspect request=highlight`, `:parse` IL, and `:typeof` on CHR 7.23.3
904
+ (stable) and 7.24rc3 (testing):
905
+
906
+ This intentionally follows the live parser rather than the scripting manual's
907
+ broader prose that a `#` starts a comment. The same live probes distinguish a
908
+ comment, a literal hash value, and a hard error at the exact byte.
909
+
910
+ | Position | Device reading | Offline rule |
911
+ | -------- | -------------- | ------------ |
912
+ | Statement-leading at the document root, after a real statement separator, in a `do`/`else`/`foreach do` body, or immediately inside a `[…]` command substitution | `comment` through newline | Mask it and preserve byte offsets. A command first consumes that lead, so `[:put #test]` keeps `#test` as the value. |
913
+ | First non-space content inside a stored-script brace such as `on-event={ # c` | `comment` | Treat exact `source`/`script` and `on-*`-named brace values as comment-bearing script bodies; a suffix such as `myScript` is not one. |
914
+ | Immediate line start after `\` + newline | `comment`; the pending statement survives | Mask it in both argument views; the continuation-reach rules remain H5/#215. |
915
+ | Inside an array (`{#test}`, `{1;#test}`, `{a=1;#b=2}`) or parenthesized expression (`(1,#test)`) | hard `error` at `#`; `:parse` reports `syntax error` | Do not mask it or emit an array value hint; semantic resolution stops there. |
916
+ | A scope-named brace (`do=`/`else=`/`command=`/`script=`/`source=`/`on-*=`) nested inside an array (`:local z {do={ # c⏎:put 1}}`, `:local z {script={ # c⏎…}}`, `:local z { { # c…}}`) — and likewise a bare `{` or `/menu {` inside an array | hard `error` at `#`; `/console/inspect` request `highlight` classes that byte `error` on CHR 7.23.3 (grounded in #249 on the four `#249` rows plus `bare {`/`source`/`on-event` corners) | A brace bears statements only when its enclosing context already does — `enclosing && braceStartsStatements(…)` on each walker's stack (`contexts`/`delimStack`/`frames`/`stack`). Inside an array the name is an array key and its `{…}` is a nested array. A `[…]` substitution nested in that array does re-enter a statement context, so `[:do { # c…}]` stays a real comment and a bare `{` under a bracket does too. |
917
+ | Inside a `[…]` substitution that is itself nested in an array (`{[:put #test]}`, `{1;[:put #test]}`) | literal value (`none`) — the bracket restores the statement role the array dropped | Keep it as content and keep the enclosing array readable. The role is per frame, so an array or group opened again inside that bracket (`{[:put {#test}]}`, `{[:put (1,#test)]}`) is `error` once more; skipping whole `[…]` regions would wrongly accept those. |
918
+ | In an attribute or bare value (`comment=#test`, `comment=a#b`, `:global y #test`) | literal value (`none` highlight class) | Keep it as content. |
919
+ | Inside a quoted run | string content | Keep it as content. |
920
+ | After a closing scope brace (`} # c`) | hard `error` at `#` | Do not mask it. |
921
+ | After a complete fixed-arity scripting directive (`:local x 2 #`, `:set x 3 #`, `:put 2 #`) | hard `error` at `#`; `:parse` reports `expected end of command` | Emit `explain/canonicalizer/invalid-hash` at the first such byte and fail closed after it. A hash in the still-open value slot (`:local x #test`, `:set x #test`, `:put #test`) remains content. |
922
+
923
+ An otherwise unrecognized `name={…}` is structurally an expression/array, not
924
+ a stored-script body. Whether the target command's schema accepts an array at
925
+ that argument is a separate live schema/runtime question; comment classification
926
+ does not imply type acceptance.
927
+
928
+ This distinction is why brace role lives in one shared primitive below the
929
+ segmenter, block reader, and symbol resolver. The `#249` widening adds the
930
+ single conjunct `enclosing && braceStartsStatements(…)` at the four walker
931
+ sites so a scope-named or bare brace inside an array is another array.
932
+ The strict argument lexer now uses the same comment-masked structural view as value anchoring, so a continuation
933
+ comment cannot fabricate positional operands or downgrade a tested REST shape.
934
+ The 948-script corpus contains no such continuation-comment argument case. The same holds for #249:
935
+ the corpus re-measurement below shows no readable/abstention movement — no
936
+ `do=`/`else=`/`command=`/`script=` brace sits inside an array in the 948
937
+ scripts, so the fix closes a device-grounded false `pass` with zero corpus
938
+ blast radius. The readable set stayed exactly 7,385 statements. Recognizing a bracket-leading hash
939
+ as a comment conservatively folds two previously separate abstentions inside
940
+ one foreign kernel-panic transcript (`[#1]` / `[#2]`) into its already-unknown
941
+ outer statement, leaving 14,329 argument-bearing candidates and 6,944
942
+ abstentions (48.4612%). That two-statement movement is a blast-radius
943
+ measurement, not the grounding evidence; the two CHR versions above are the
944
+ oracle. (Those are the #249-era figures; the #285 catalog correction has since
945
+ moved the readable set to 7,392 — see the re-measurement below.)
946
+
947
+ The same corpus run finds five new `invalid-hash` diagnostics, all in pasted
948
+ non-RouterOS material: one NGINX server block, one JavaScript highlighter, one
949
+ JSON-like transcript, and two shell-shaped snippets. No RouterOS command reading
950
+ moved; these diagnostics say those bytes are not valid RouterOS, not that the
951
+ foreign snippets were expected to parse as RouterOS.
952
+
953
+ The default text renderer surfaces comment spans in a `comments:` section; JSON
954
+ continues to carry the same ranges as `spans[]` entries with `class: "comment"`.
955
+
956
+ Corpus re-measurement for #249 — `bun -e 'explainCommand'` over the 948-script
957
+ `lsp-routeros-ts/test-data/corpus.sqlite` (same harness as § *What `offline` actually emits*):
958
+
959
+ - `stmts` 18,648 · `argCandidates` 14,329 · `argReadable` 7,385 · `abstentions`
960
+ 6,944 (48.4612%) — **identical** before and after the `enclosing &&`
961
+ conjunct (stashed vs patched, verified by `git stash --keep-index`).
962
+ - `invalid-hash` diagnostics: 5 files / 5 diagnostics both before and after —
963
+ the widening has no corpus hit, because the corpus contains no `do=`/`else=`/
964
+ `command=`/`script=` brace nested in an array. Zero blast radius is the
965
+ expected outcome; the grounding remains the 15-input CHR probe below
966
+ (CHR 7.23.3 `highlight` at the `#` byte, 0/15 mismatches), not a corpus delta.
967
+
968
+ The #285 catalog correction moved those figures, and only upward. Same harness,
969
+ old table vs new:
970
+
971
+ | | #249 | #285 |
972
+ | --- | ---: | ---: |
973
+ | `stmts` | 18,648 | 18,648 |
974
+ | `argCandidates` | 14,329 | **14,336** |
975
+ | `argReadable` | 7,385 | **7,392** |
976
+ | `abstentions` | 6,944 | 6,944 (**48.4375%**) |
977
+ | `ambiguous-statement` | 91 | **85** |
978
+ | `unresolved-statement` | 3,911 | **3,910** |
979
+ | `context-lost` | 69 | **83** |
980
+
981
+ Six scripts move, and every statement in them is a correction. Seven statements
982
+ that read as `ambiguous`/`unknown` now resolve and their arguments read —
983
+ `/interface/monitor-traffic`, `/system/package/apply-changes`,
984
+ `/system/package/update/{check-for-updates,download,install}` — which is the
985
+ whole of the `argCandidates`/`argReadable` gain, with `abstentions` flat. Five
986
+ more read the same but with the right verb: `/ip hotspot user reset-counters
987
+ [find …]` took `hotspot` as the verb and now takes `reset-counters`, which also
988
+ reclassifies it as a write. No document changes `containsWrite` verdict.
989
+
990
+ `context-lost` rising is the resolver **withdrawing** a certainty claim, not
991
+ losing ground. It is `info` severity and marks a statement that resolved
992
+ correctly while the document context was already unknown. All 14 are in one
993
+ script — a bare list of command paths, one per line, with no navigation
994
+ anywhere. Previously `/interface/monitor-traffic` was unreadable and so *might*
995
+ have been an absolute navigation, which R4 has REPLACE the context; certainty
996
+ was restored on that maybe. Knowing it is a command, the resolver knows it never
997
+ navigated, so the context stays as unknown as it already was.
998
+
999
+ - **Severity is fixed here, because it drives `--fail-on`.** Three buckets, and
1000
+ the split is not "structural vs not":
1001
+ - `error` — `unclosed`, `unbalanced-close`, `unterminated-string`,
1002
+ `bad-escape` (code-position `\` + non-whitespace), `bad-string-escape`
1003
+ (string-internal unknown/lowercase-hex/truncated — CHR `highlight` `error`
1004
+ `:parse` `expected message value`, grounded stable+testing #247),
1005
+ `bad-sigil`,
1006
+ `invalid-hash`. Seven classes the device itself rejects.
1007
+ - `warning` — `over-depth`, because it is centrs's own resource bound and says
1008
+ nothing about whether the input is legal; and an `ambiguous`/`unknown`
1009
+ resolution, never an error, so the default `--fail-on error` cannot fail a
1010
+ document whose only sin is being unreadable without a schema — which is most
1011
+ of RouterOS scripting.
1012
+ - `info` — `bom`/`non-ascii` (positional facts: a legal command must not
1013
+ fail), and `context-lost`, which reports a reading that is correct while the
1014
+ document's menu context was already gone.
1015
+
1016
+ ### String escape validation (#247, #252)
1017
+
1018
+ The accepted set is the **device's**, not the manual's. The
1019
+ [Scripting manual's constant-escape table](https://manual.mikrotik.com/docs/developer-guides/scripting/#constant-escape-sequences)
1020
+ is a *lower bound*: it omits `\?`, and it documents the whitespace continuation
1021
+ only under a separate "Line joining" section. Treating that table as a closed
1022
+ allow-list is what #251 did and what #252 had to undo — it made 44 device-valid
1023
+ corpus scripts read `fail`. The set below comes from sweeping `:put "\<c>"` over
1024
+ every byte `0x20`–`0x7E` plus TAB/LF/CR/CRLF on CHR 7.23.3, scored on both the
1025
+ `/console/inspect request=highlight` class and the runtime result:
1026
+
1027
+ - `\"` `\\` `\n` `\r` `\t` `\$` `\_` `\?` `\a` `\b` `\f` `\v`
1028
+ - `\XX` where `XX` is **uppercase** hex
1029
+ - `\` followed by whitespace (space, TAB, CR, LF, CRLF) — a **line
1030
+ continuation**, valid inside a string and not just in code. The device
1031
+ swallows the pair and emits nothing (`:put "a\ b"` → `ab`). CRLF counts as one
1032
+ continuation, so it is the only non-hex three-byte escape.
1033
+
1034
+ Everything else is rejected, including `\` before a non-ASCII byte. `\$` is
1035
+ grounded on `highlight` (`escaped,escaped`, no `error` on CHR 7.23.3/7.24rc3)
1036
+ and the manual; the `:parse` wrapper (`:put [:parse "..."]`) is not used for
1037
+ that escape because outer-string escaping makes the inner `\$` appear as `$` to
1038
+ the parser — for the same reason #252's rows are grounded by executing the input
1039
+ directly rather than through the wrapper. Grounded on CHR 7.23.3 stable and
1040
+ 7.24rc3 testing (`highlight` hard `error` + `:parse` `expected message value`
1041
+ for the three issue rows `:put "\q"` / `:put "\x0a"` / `:put "\0a"` vs
1042
+ `":put "\0A"` valid):
1043
+
1044
+ - A malformed escape (`\q`, `\x`, `\x0a`, truncated single-hex `\0`, lowercase
1045
+ hex `\0a`/`\5f`) is a located `bad-string-escape` structural defect
1046
+ (severity `error`) at the byte the device marks `error` — the unknown char or
1047
+ the lowercase second hex digit. For a truncated single-hex escape the device
1048
+ marks the closing quote; centrs reports at the backslash instead, because that
1049
+ byte is stable when the string is unterminated.
1050
+ - `\ff` is `\f` + literal `f` and `\aF` is `\a` + `F`; neither is invalid hex.
1051
+ - The one shared `scanQuotedString` below every walker still **recovers the
1052
+ closing quote** past a malformed escape, so no #199 nested-substitution
1053
+ quote-phase bug is reintroduced. Validation is a shared
1054
+ `collectStringEscapeDefects` walk with the same substitution frames; only the
1055
+ first invalid escape per document is reported and it fails closed after it.
1056
+ - Corpus blast radius (948 scripts): **4** `bad-string-escape` diagnostics (vs
1057
+ 15 code-position `bad-escape`), and the device rejects all 4. Under #251's
1058
+ closed allow-list this was 62 flags of which **44 were false positives on
1059
+ valid RouterOS** — mostly multi-line `source=`/`on-event=` strings (`\`+LF)
1060
+ and `\?`.
1061
+ - Scoring a defect rule against the corpus needs the right oracle.
1062
+ `parseil_results.ok` is **not** one: `:parse` never throws, it returns a
1063
+ diagnostic *value*, so the capture records `ok=1 / status="ok"` for scripts
1064
+ the device rejected (only 3 of 2739 rows are `ok=0`, and those are capture
1065
+ failures). The verdict is in `il_text` — plain prose such as
1066
+ `expected input value (line 7 column 49)` means rejected, IL means accepted.
1067
+
1068
+ ### The operator surface (#255)
1069
+
1070
+ Grounded, not transcribed, for the same reason the escape set above is: the
1071
+ [manual's operator list](https://manual.mikrotik.com/docs/developer-guides/scripting/index.md#operators)
1072
+ is a lower bound. `bun run explain:probe:operators` sweeps the manual's list
1073
+ **plus** plausible non-operators (`not`, `..`, `xor`, `mod`, `is`, `div`, …)
1074
+ **plus** every IL head the corpus census saw, on CHR 7.23.3 stable, 7.24rc4
1075
+ testing and 7.21.5 long-term — one build per release channel, so a claim about
1076
+ any channel is evidence rather than an assumption. Re-cut the fixture with
1077
+ `bun run explain:operator-slice`; the table
1078
+ below is generated from `src/explain/operators.ts` by
1079
+ `bun run explain:operator-readme` and gated by
1080
+ `bun run explain:operator-readme:check`.
1081
+
1082
+ **`:parse` IL is the oracle, and `highlight` cannot be.** IL is prefix form with
1083
+ the operator as its node's head, so it names the operator and shows its
1084
+ operands. `highlight`'s `syntax-meta` is the device's *residual structure*
1085
+ class — measured over `test/fixtures/explain/highlight-streams.slice.json` it
1086
+ covers `=`, `"`, `$`, brackets, braces, parens, `;`, `,`, `/` and whitespace
1087
+ runs as well as `||`, `&&`, `!=`, `.` and `~` — and adjacent runs are **merged**
1088
+ (`="`, `($`, `")+` each arrive as one token; the run around `and`, `or` and `in`
1089
+ swallows the preceding space). An operator boundary is not recoverable from it.
1090
+ What it does decide is *structure versus word*, which is what rules `not` out:
1091
+ it comes back `variable-undefined`.
1092
+
1093
+ Five things the device says that the manual does not:
1094
+
1095
+ 1. **`not` is not an operator.** Nor are `xor`, `mod`, `is`, `div`, `band`,
1096
+ `bor`, `shl`, `shr`, `eq`, `ne`. Inside a paren group a bare word is a
1097
+ **variable reference**, so `(1 not 2)` parses — as `( 1 $not 2)`, with an
1098
+ unnamed juxtaposition node. "It parsed" is therefore not evidence of an
1099
+ operator, which is what the sweep's `(1 zzz 2)` control exists to prove.
1100
+ 2. **`..` is not a range operator** — `(1 .. 2)` is `( (. 1 $.) 2)`, concat
1101
+ applied to a variable named `.`. Same for `//`. And `<>` is not "not equal":
1102
+ `(1 <> 2)` is `(< 1 (> 2))`, less-than applied to a *deferred* `2`.
1103
+ 3. **`$`, `[`, `]` are syntax, not operator heads.** `$x` stays an atom in the
1104
+ IL and `[:tostr 1]` lowers to an `evl` node. Those bytes belong to the
1105
+ substitution axis.
1106
+ 4. **`any` is an operator** (prefix, arity 1) and is not in the manual's list — a **nil-check**: `:typeof (any x)` is `bool`, `false` only for `nil`/`nothing` (the value of an undefined `:local` and of `[:nothing]`), `true` for everything else including `0`, `""` and `false`. It is the idiom `:if (any $x) ...` to test a variable that may be `nil`; `(true any false)` is not infix at all but juxtaposition `( true (any false))`, and `(1 . any [:nothing])` is concat `1`+`false`. Present since at least 7.20.8 (corpus `any|7.20.8:2`, and swept live on 7.21.5 long-term, 7.23.3 stable and 7.24rc4 testing with no difference between them).
1107
+ 5. **`&&` and `||` are spellings**, lowering to the `and` and `or` nodes.
1108
+
1109
+ The `(>…)` and `<%%` forms are in the table on the same footing as `+`.
1110
+ `(> x)` at arity 1 is the deferred-expression form — `[:typeof (>[:return 1])]`
1111
+ is `op` — while `(2 > 1)` at arity 2 is the comparison; **arity is the only
1112
+ thing that separates them**, so a table keyed on spelling alone gets one wrong.
1113
+ `<%%` applies a deferred expression to an argument array, binding positionals
1114
+ from `$0`; a `do={…}` function binds the same arguments from `$1`, because
1115
+ there `$0` is the function's own name.
1116
+
1117
+ Both `!` and `any` are prefix-only, so the *pair* sweep cannot carry them and
1118
+ their `precedence` is honestly `null`. That is "unmeasured", not "unknown": the
1119
+ sweep asks them separately, every `(U 1 B 2)` and `(1 B U 2)` for
1120
+ `U`∈`!`,`any`,`~`,`-`,`>` against all 24 binaries — 240 probes, recorded in the
1121
+ fixture's `unary` block. All 240 are accepted and the **binary is outer in every
1122
+ one**, on all three versions, so each prefix operator binds tighter than every
1123
+ binary including `->` (14) and `<%%` (13). The table stores one `precedence` per
1124
+ spelling, which is the binary level; `~`, `-` and `>` are here because their
1125
+ unary reading has no other record.
1126
+
1127
+ Spacing decides the *tokens*, not just the tree. `(1.2)` is an IP literal and
1128
+ `(.1)` a time literal, but the rest of the matrix is where a tokenizer goes
1129
+ wrong: `(1 . 2)` and `(1 .2)` are both concat (`(. 1 2)`), while **`(1. 2)` is
1130
+ not concat at all** — `1.` lexes as a *variable name* and the row comes back as
1131
+ juxtaposition, `( $1. 2)`. A rule that claims every `.` byte for the operator
1132
+ emits a span the device does not have.
1133
+
1134
+ Everything in the sweep is identical across 7.21.5, 7.23.3 and 7.24rc4 except
1135
+ one runtime row: `:put ({2;1} > {1;2;3})` evaluates to `true` only on 7.24rc4.
1136
+ Identical is measured, not assumed — the slice diffs verdict, arity, precedence,
1137
+ associativity, `highlight` run, unary placement and op-axis per version. So the
1138
+ operator table needs no version gate; anything reporting what a comparison
1139
+ *means* does.
1140
+
1141
+ <!-- BEGIN GENERATED operator-table — regenerate with `bun run explain:operator-readme` -->
1142
+ The device builds a node for **26** spellings, reads **3**
1143
+ as something else, and refuses the other **33** the sweep asked about.
1144
+
1145
+ | operator | arity | precedence | associativity | category |
1146
+ | -------- | ----- | ---------- | ------------- | -------- |
1147
+ | `,` | 2 | 1 | variadic | concatenation |
1148
+ | `or` | 2 | 2 | variadic | logical |
1149
+ | `and` | 2 | 3 | variadic | logical |
1150
+ | `in` | 2 | 4 | left | logical |
1151
+ | `<` | 2 | 5 | left | relational |
1152
+ | `>` | 1, 2 | 5 | left | relational |
1153
+ | `=` | 2 | 5 | left | relational |
1154
+ | `<=` | 2 | 5 | left | relational |
1155
+ | `>=` | 2 | 5 | left | relational |
1156
+ | `!=` | 2 | 5 | left | relational |
1157
+ | `~` | 1, 2 | 5 | left | relational |
1158
+ | `.` | 2 | 6 | variadic | concatenation |
1159
+ | `<<` | 2 | 7 | right | bitwise |
1160
+ | `>>` | 2 | 7 | right | bitwise |
1161
+ | `\|` | 2 | 8 | variadic | bitwise |
1162
+ | `^` | 2 | 9 | variadic | bitwise |
1163
+ | `&` | 2 | 10 | variadic | bitwise |
1164
+ | `+` | 2 | 11 | variadic | arithmetic |
1165
+ | `-` | 1, 2 | 11 | left | arithmetic |
1166
+ | `*` | 2 | 12 | variadic | arithmetic |
1167
+ | `/` | 2 | 12 | left | arithmetic |
1168
+ | `%` | 2 | 12 | left | arithmetic |
1169
+ | `<%%` | 2 | 13 | right | apply |
1170
+ | `->` | 2 | 14 | left | access |
1171
+ | `!` | 1 | not measured | not measured | logical |
1172
+ | `any` | 1 | not measured | not measured | logical |
1173
+
1174
+ Precedence runs 1 (loosest) to 14 (tightest), measured over every
1175
+ ordered pair rather than transcribed. `variadic` means the device FLATTENS
1176
+ the operator — `(1 + 2 + 3)` is one node with three children, not two nested
1177
+ ones. The 2 prefix-only operators never appear in a pair and so
1178
+ carry no measured level.
1179
+
1180
+ Spellings the device reads as something else:
1181
+
1182
+ | spelling | reads as | kind |
1183
+ | -------- | -------- | ---- |
1184
+ | `&&` | `(<%% (and 1 2) )` | alias |
1185
+ | `\|\|` | `(<%% (or 1 2) )` | alias |
1186
+ | `<>` | `(<%% (< 1 (> 2)) )` | re-lexed |
1187
+
1188
+ And the grounded complement — asked, and refused:
1189
+
1190
+ | spelling | why not |
1191
+ | -------- | ------- |
1192
+ | `not` | reads-as-variable |
1193
+ | `xor` | reads-as-variable |
1194
+ | `mod` | reads-as-variable |
1195
+ | `is` | reads-as-variable |
1196
+ | `div` | reads-as-variable |
1197
+ | `band` | reads-as-variable |
1198
+ | `bor` | reads-as-variable |
1199
+ | `shl` | reads-as-variable |
1200
+ | `shr` | reads-as-variable |
1201
+ | `eq` | reads-as-variable |
1202
+ | `ne` | reads-as-variable |
1203
+ | `line` | reads-as-variable |
1204
+ | `array` | reads-as-variable |
1205
+ | `as` | reads-as-variable |
1206
+ | `at` | reads-as-variable |
1207
+ | `for` | reads-as-variable |
1208
+ | `none` | reads-as-variable |
1209
+ | `outside` | reads-as-variable |
1210
+ | `evl` | reads-as-variable |
1211
+ | `..` | reads-as-variable |
1212
+ | `//` | reads-as-variable |
1213
+ | `**` | rejected |
1214
+ | `===` | rejected |
1215
+ | `+=` | rejected |
1216
+ | `++` | rejected |
1217
+ | `?` | rejected |
1218
+ | `?:` | rejected |
1219
+ | `<%` | rejected |
1220
+ | `%%` | rejected |
1221
+ | `;` | rejected |
1222
+ | `$` | juxtaposition-only |
1223
+ | `[` | rejected |
1224
+ | `]` | rejected |
1225
+ <!-- END GENERATED operator-table -->
1226
+
1227
+ <!-- BEGIN GENERATED token-census — regenerate with `bun run explain:token-census:readme` -->
1228
+ The token census is re-derivable with `bun run explain:token-census` and
1229
+ covers 948 source scripts. The figures below are generated from
1230
+ `test/fixtures/explain/tokens.json` → `corpus` by
1231
+ `bun run explain:token-census:readme` and gated against it by
1232
+ `bun run explain:token-census:readme:check`; the fixture itself is gated
1233
+ against a fresh corpus run by `bun run explain:token-census:check`. Of
1234
+ 1,426,731 analyzed bytes, 990,840 are classified (69.45%), the remaining
1235
+ 435,891 are `unclassified`. The census emits 205,072 tokens (avg 216.3 per
1236
+ script). Every byte belongs to exactly one token — sorted by `start`, no
1237
+ gaps, no overlaps, `join(slice) === input` — and the `class` field is
1238
+ provisional until #264 B5. Each B2 fill should move the classified
1239
+ percentage.
1240
+ <!-- END GENERATED token-census -->
1241
+
1242
+ B1's `data.tokens[]` is live behind `--tokens` — a total, gapless byte
1243
+ partition whose `class` is provisional until #264 B5 (every unclaimed byte is
1244
+ `unclassified`). Since B1 its only fill source was `data.spans[]` (comment runs
1245
+ and resolved variable occurrences); **the path fill claims resolved menu and
1246
+ command-name bytes as provisional `dir` / `cmd` tokens**, including valid path
1247
+ slashes and nested command substitutions, while ambiguous, malformed, and
1248
+ source-unmapped runs stay unclassified. **`#293`'s arg fill** in
1249
+ `src/explain/arg-tokens.ts` then claims argument names and
1250
+ their `=` (name run
1251
+ `[span.start, valueSpan.start - 1)` plus the single `=` byte at
1252
+ `valueSpan.start - 1`) on the residual left by spans, before operators see it
1253
+ — and **`#295`'s value fill follows it** — `src/explain/value-tokens.ts`
1254
+ claims argument value bytes and leaf array-literal members
1255
+ (`data.values.occurrences`, leaves only via `parent` containment,
1256
+ quotes-included) on the residual left by `spans`+`path`+`arg`, before the
1257
+ **B3 string/brace fills** see it — `src/explain/string-tokens.ts` claims
1258
+ quoted-string runs (delimiters included) and `src/explain/brace-tokens.ts`
1259
+ claims every residual brace delimiter (`{`/`}`), including delimiters from
1260
+ invalid braced references, on the residual left by
1261
+ `spans`+`path`+`arg`+`value`, and finally `operator` sees only what none of
1262
+ them wanted — which is why the operator fill can abstain on `, / = -` outside
1263
+ `( )`, on bytes glued after an argument `=`, and on bytes glued into an
1264
+ argument name without a smarter operator scanner. Fill order is enforced by
1265
+ argument order to `buildTokens` ([#290 design decision 1](../../src/explain.ts)):
1266
+ `spans` claims first, then `path`, `arg`, `value`, `string`, `brace`, and
1267
+ finally `operator`.
1268
+ `ExplainTokenClass` is
1269
+ `ExplainSpanClass | "dir" | "cmd" | "operator" | "arg" | "value" | "string" |
1270
+ "brace" | "unclassified"` — one
1271
+ provisional `operator` class for all 26 spellings + 2 aliases, one provisional
1272
+ `arg` class for both the name bytes and the `=` separator, one provisional
1273
+ `value` class for every leaf value span, plus provisional `string` (quoted
1274
+ runs) and `brace` (scope delimiters) — emit first, name later (whether the `=`
1275
+ or per-shape `value` later deserve their own classes is #264 B5 and does not
1276
+ move the byte coverage), not per-operator/per-category/shape (`#264` B5).
1277
+ `ExplainSpanClass` and `data.spans[]` stay proof-only; `src/explain/operators.ts`
1278
+ remains data plus accessors, and the operator table above is its source. The
1279
+ `centrs → highlight` projection is B4 and reads both.
1280
+
1281
+ **Where the operator fill abstains.** Fill order is the resolution mechanism, so
1282
+ a byte that is structurally part of a path or an argument is left
1283
+ `unclassified` for the path/arg fills rather than claimed. Three abstentions,
1284
+ each grounded on the corpus device oracle (`parseil_results.il_text`):
1285
+
1286
+ | Abstention | Grounding |
1287
+ | ---------- | --------- |
1288
+ | `, / = -` outside a `( )` group. `(` opens an expression; `[` opens a **command substitution** and `{` a block or array literal. | The IL for `[ /system/identity/get value-name=name ]` is `(evl /system/identity/get value-name=name)` — path separators and an argument separator, no division and no comparison node. |
1289
+ | A spelling glued immediately after an argument `=`. | `in-interface-list=!LAN`, `.id=*2`, `oid=.1.3.6.1.2.1` — the byte after an argument `=` starts the value. |
1290
+ | A spelling glued into an argument **name**. | `:foreach x in=$list` is `/foreach counter=$x` with no `(in …)` node anywhere; the IL keeps `security.authentication-types=wpa2-psk` as one name and renders `.id` as the single symbol `$.id`, never `(. …)`. |
1291
+
1292
+ The first abstention has a measured cost: `find where name="x"` inside `[ … ]`
1293
+ *does* lower to a real `(= $name x)` node, so 224 of the corpus's 1,259
1294
+ bracket-interior `=` bytes are genuine comparisons that now stay
1295
+ `unclassified` — against 1,035 that were plain `arg=value`. A `where`-aware
1296
+ fill can take them back later; claiming all 1,259 would be 82% wrong.
1297
+
1298
+ ### Designed, not implemented (the CLI surface, #202b)
1299
+
1300
+ `src/cli/explain.ts` ships the offline command; implemented flags are generated
1301
+ into [`docs/CLI.md`](../../docs/CLI.md) and are not restated here. What the
1302
+ [Surface](#surface-option-a--decided) table above describes and the command does
1303
+ **not** yet accept:
1304
+
1305
+ | Flag / form | Phase | Why it is not accepted yet |
1306
+ | ----------- | ----- | -------------------------- |
1307
+ | `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. |
1308
+ | `--cursor <byte>` | 2 | It positions `--complete`, which enumerates nothing offline. A cursor with no candidate surface to position is a flag with no effect. |
1309
+ | `--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. |
1310
+
1311
+ `--complete` and `--schema` **are** accepted, because they have an offline
1312
+ behavior the spec names: they enumerate nothing and emit the live-target tip.
1313
+ That is the difference — an accepted flag must do something observable.
1314
+
1315
+ ## MCP and library surfaces
1316
+
1317
+ - **The cross-project handoff is the structure layer, not the token layer.**
1318
+ `explain`'s data is three layers: byte ranges and their classes
1319
+ (`spans`, and `tokens` when #264 lands) — centrs-owned, and nothing outside
1320
+ centrs consumes them; the resolved reading (`structure`, `symbols`,
1321
+ `values`) — path, verb, argument names, value spans; and doc/version
1322
+ enrichment, which is rosetta's (#175). A consumer that wants enrichment takes
1323
+ the **middle** layer: given `{path, verb, args}` plus a version it does its
1324
+ own lookups, whereas handing it tokens would make it re-derive from bytes a
1325
+ path centrs already resolved. So #223 is a decision about the structure
1326
+ layer, and the token vocabulary (#264) is deliberately outside its scope —
1327
+ the two do not need to be sequenced against each other. The same split says
1328
+ where a schema-shaped gap belongs: the per-path argument list `catalog.ts`
1329
+ refuses to be is enrichment, not lexing.
1330
+ - `centrs_explain` today wraps `canonicalizeExecuteCommand` (offline, no CDB —
1331
+ the only tool that serves without one). It grows toward this spec
1332
+ **following the CLI scheme** (decision): same facets, same envelope-shaped
1333
+ data as the CLI's `--json`, optionally a CDB-resolved target for live
1334
+ evidence. The no-CDB offline path must survive, and the MCP adapter must
1335
+ not widen the structured gate. The current flat `centrs_explain` shape
1336
+ (`{ input, mode, path, verb, attributes, queries, writeShaped }`) is
1337
+ **superseded** when this lands — a deliberate pre-1.0 breaking change, with
1338
+ `commands/mcp/` examples and integration tests updated in the same change;
1339
+ no dual-shape compatibility layer is planned. **That supersession is held out
1340
+ of #202 and tracked in #223** (maintainer decision): `centrs_explain` and
1341
+ rosetta's tool surface must stay aligned for agent usage, which is a
1342
+ cross-project decision. Until #223 lands the MCP keeps its flat shape, so the
1343
+ CLI's `--json` and the MCP's `data` are **deliberately divergent**.
1344
+ - Library: `explainCommand(input, options?: ExplainCommandOptions)` is the
1345
+ offline analysis (`src/explain.ts`, #202a/#202c) and
1346
+ `explainEnvelope(input, options?: ExplainEnvelopeOptions)` wraps it in the
1347
+ standard envelope. Both option types are exported. Phase 2 extends that same
1348
+ analysis bag with `target` and `facets`; `curl` stays an orthogonal rendering
1349
+ request, so offline and live calls do not need competing second-argument
1350
+ shapes. `lsp-routeros-ts` and
1351
+ tikbook are the intended external consumers (hover/diagnostics/completion/
1352
+ semantic tokens over these calls); an LSP *protocol* surface on centrs
1353
+ stays out of scope (#90) — but the export shape is validated against a real
1354
+ LSP consumption spike before it hardens (staging phase 4).
1355
+
1356
+ ## Non-goals
1357
+
1358
+ - No LSP server in centrs — the LSP is a consumer the library must support,
1359
+ nothing more.
1360
+ - No execution probes; no state mutation of any kind.
1361
+ - No fan-out.
1362
+ - No rosetta/restraml integration at runtime (decision, amended by #228): no
1363
+ shell-outs, no calls, no vendored *schema* artifact, no version-history facts.
1364
+ The two generated structure tables are built from pinned public sources by
1365
+ scripts in `scripts/`, and centrs vendors its own CLI-Reference ETL rather
1366
+ than depending on rosetta for it. Output may tip toward rosetta for
1367
+ docs/version questions; deeper integration waits on centrs's shape settling
1368
+ and rosetta publishing stable consumable surfaces.
1369
+ - No promise of enum exhaustiveness or of the upstream `completion-tricks`
1370
+ synthetic-probe recipes (still open research in lsp-routeros-ts
1371
+ `BACKLOG.md`) — candidates are labeled as observed, not closed sets.
1372
+
1373
+ ## Phase-0 ratification (#185)
1374
+
1375
+ The phase-0 canonicalization grounding lab (#185, rev 2) is complete. It scored
1376
+ a schema-free offline parser against device truth — `:parse` IL and
1377
+ `/console/inspect` highlight, cross-checked on CHR 7.23.2 and 7.24rc2 — over a
1378
+ frozen train/holdout split of the 913-script lsp-routeros-ts corpus, plus
1379
+ constructed corner and mutation suites. Each question below names the spec claim
1380
+ it gated and links its exit artifact (the artifact carries the fixture/split
1381
+ hashes, RouterOS versions, oracle source, per-class precision/recall/abstention,
1382
+ and disagreement samples). Probes are throwaway (`.scratch/`, non-authoritative);
1383
+ the findings here are the deliverable, promoted per
1384
+ `.github/instructions/routeros-grounding.instructions.md`.
1385
+
1386
+ **Ratified as designed:** Q1 statement segmentation · Q2 block topology · Q3
1387
+ `[]` sub-command path re-constitution · Q4 stateful-per-document path context ·
1388
+ Q6 no-schema verb/menu boundary (**decision 3 unchanged**) · Q8 tested REST
1389
+ mapping rules + fail-closed boundary · Q10 `.proplist` recipe · Q13 symbol scope
1390
+ classes.
1391
+
1392
+ **Amended the surface (folded in above):**
1393
+
1394
+ | Finding | Amendment | Exit artifact |
1395
+ | ------- | --------- | ------------- |
1396
+ | **Q16** write-shape | `structure.containsWrite` is three-valued `true \| false \| unknown` (boolean rejected as unsafe); zero false negatives on obvious writes; `unknown` separate from `false` | [#5027578882](https://github.com/tikoci/centrs/issues/185#issuecomment-5027578882) |
1397
+ | **Q6/Q3/Q13/Q14** schema-free floor | a distinct **`ambiguous`** canonicalizer verdict; fail-closed rule promoted to the constitution | [#5025480566](https://github.com/tikoci/centrs/issues/185#issuecomment-5025480566) |
1398
+ | **Q14** malformed/partial | diagnostics carry a **defect region**; fail-closed recovery (no invented commands after a defect; bare-word heads → `unknown`); `--complete` bounded offline | [#5048400737](https://github.com/tikoci/centrs/issues/185#issuecomment-5048400737) |
1399
+ | **Q15** coordinates | ASCII-normalization is **byte-count-preserving** (non-ASCII byte → placeholder, one-for-one) so analyzed offsets ≡ highlight byte offsets; invariants validated 100% | [#5048304595](https://github.com/tikoci/centrs/issues/185#issuecomment-5048304595) |
1400
+ | **Q11** native-api readout | the cell is **not** degraded — `:put [:parse …]` over native `/execute` with `as-string` returns line/column; the old console-only caveat is retired | [#5046924529](https://github.com/tikoci/centrs/issues/185#issuecomment-5046924529) |
1401
+ | **Q17** robustness | product parser bounds nesting depth (over-depth diagnostic) and scans single-pass; determinism + well-formed span trees are property tests | [#5040370326](https://github.com/tikoci/centrs/issues/185#issuecomment-5040370326) |
1402
+
1403
+ **Pre-registered thresholds — outcomes:** `api-candidate`/write-shape precision
1404
+ and coordinate invariants met; the **one hard-0 threshold that was *not* met is
1405
+ Q14's "no fabrication after the first defect"** — the throwaway lab walker
1406
+ invents a command in 3 of 76 mutants, which is exactly why the fail-closed
1407
+ bare-word rule (b) above is a **hard product requirement**, not a nicety. The lab
1408
+ probes were left unpatched by design; the guard belongs in the product parser.
1409
+
1410
+ **Corpus coverage caveat (#203).** Every figure above was measured on the
1411
+ 913-script corpus, which is **96.8% two forum authors** (`amm0` 630,
1412
+ `rextended` 254) and **53.8% scripting-directive-bearing**. Pure `add`/`set`
1413
+ configuration is **2.3%** (21 files), genuine device-emitted `/export` output
1414
+ is **1.0%** (9 files), and the three `/export` serializations — `compact`,
1415
+ `terse`, `verbose` — were never captured at phase 0. Nearly every file also
1416
+ opens with a harness-injected `# Source: …` line the device never wrote (884
1417
+ of 913). The ratified answers hold for what was measured; they are **not**
1418
+ evidence of coverage over device configuration, which is the genre most likely
1419
+ to be handed to `explain` and the one that concentrates Q4 path context and
1420
+ Q6's bare-path ambiguity. Quote phase-0 numbers per genre, never as one blended
1421
+ figure. #203 tracks the capture and the re-score.
1422
+
1423
+ Those figures are re-derivable, not eyeballed: `bun run explain:corpus-census`
1424
+ (`scripts/explain-corpus-census.ts`) reproduces the whole genre table from
1425
+ `corpus.sqlite`, with a per-collection stratum breakdown, and is the source of
1426
+ truth over #203's prose where the two disagree — it corrects the issue's
1427
+ export-banner count (9, not 8: the issue's own 22 − 13 shebangs) and its
1428
+ pure-config count (21, not 23: the first pass counted MAC addresses, IPv6
1429
+ literals, and DHCP client-ids as `:` directives, which under-counts exactly the
1430
+ config genre in question). Since phase 0 the corpus has grown to 948 with a
1431
+ `tangentsoft` stratum of 35 genuine `/export … terse` device captures, taking
1432
+ export-banner share to 4.6%; **that stratum is not part of any phase-0 figure
1433
+ above**, and no promoted module has been scored against it yet (#203
1434
+ deliverable 2).
1435
+
1436
+ The corpus itself is not in this repo and is not moving here — `lsp-routeros-ts`
1437
+ owns producing snapshots, centrs owns which snapshot it measures against.
1438
+ `scripts/corpus-pin.json` is that second half: the commit and the blob sha256 a
1439
+ census run measures. It identifies the **current** 948-script snapshot only —
1440
+ the phase-0 figures above were measured on the frozen 913-script corpus and no
1441
+ pin describes them. `bun run corpus:fetch` downloads and verifies it, so the
1442
+ censuses run from a bare clone and in CI rather than on one machine (#186).
1443
+
1444
+ **All three export serializations parse clean offline (#203 deliverable 2,
1445
+ re-scored after #207).** Every one of the 120 CHR captures — `compact`, `terse`
1446
+ and `verbose` × `hide-sensitive` / `show-sensitive` × both pinned versions —
1447
+ runs through the promoted modules with **zero segmenter structural notes, 100%
1448
+ Q4 path resolution, and 0.0% Q16 abstention with no blockers anywhere in the
1449
+ stratum**. The last figure is new: before #207's baked menu table, the
1450
+ `isConfirmedNav` hyphen rule abstained on 25 distinct genuine menus. This is
1451
+ the coverage claim that matters for `explain`, and it holds.
1452
+
1453
+ **Q6's bare-path abstention is closed on the same table (#210).** `verbsplit.ts`
1454
+ originally did not consume `menus.ts`, so Q6 kept returning `ambiguous` on
1455
+ `compact`/`verbose` for exactly the bare-path lines the container table can
1456
+ decide. It now reads that table at V4 — the one case the ratified `proposed`
1457
+ rule refuses — and emits a `navigation` resolution when the path is a known
1458
+ menu. Abstention on device-emitted export goes to **0.0% on every stratum**
1459
+ (from 37.6–38.4% on `compact`/`verbose`; `terse` was already 0% by
1460
+ construction, since it never emits a bare menu line), and on the frozen corpus
1461
+ from 4.0% → **1.4%** dev and 1.2% → **0.5%** holdout.
1462
+
1463
+ The table is consulted in `resolveVerb`, never in `splitRun`, so the ratified
1464
+ schema-free rule still prices decision 3 on its own terms and Q16's abstention
1465
+ is unchanged to the decimal (44.8% dev / 46.0% holdout). Precision was checked
1466
+ against the device parser rather than asserted: over 1,689 IL (`:parse`)
1467
+ projections — which distinguish `menuctx` from `cmd` natively — the table never
1468
+ calls a menu what the device read as a command, neither across the whole table
1469
+ nor across the 104 paths this rule flips. The residual is the right residual:
1470
+ `/system/reboot`, `/quit`, `/tool/speed-test` and abbreviated forms like
1471
+ `/ip fire conn` stay `ambiguous`, because the table is a floor and absence from
1472
+ it is not evidence of a command. *(#228 has since decided all but the
1473
+ abbreviated forms — see below.)*
1474
+
1475
+ **The contradicting half of that seam is closed too (#211 B1).**
1476
+ `pathresolve.ts` claimed **every** `/`-led bare path was navigation, with no
1477
+ check that the path is a menu, so `/ip address print` navigated to the
1478
+ non-existent `/ip/address/print` while `verbsplit` read the identical text as
1479
+ verb `print` at `/ip/address` — two promoted modules, contradictory answers,
1480
+ 34 dev / 13 holdout statements, `verbsplit` right in every one. `menuNavPath`
1481
+ now consults the same frozen `VERBS` set (R9), which takes those statements to
1482
+ **0 dev / 0 holdout** at no abstention cost: they were already decided, just
1483
+ decided wrongly. The vocabulary moved to the leaf `verbs.ts` so both modules
1484
+ share one object and cannot drift apart, and the invariant — a
1485
+ `verbsplit`-`resolved` statement is never `isNav` — is pinned in
1486
+ `test/unit/explain-pathresolve.test.ts` alongside R9's premise: no path in the
1487
+ generated `MENU_PATHS` table carries a verb segment. That premise is scoped to
1488
+ the table, not to RouterOS — `menus.ts` is a floor, so it cannot rule out an
1489
+ unlisted menu named `.../print`, which R9 would read as a command.
1490
+
1491
+ R9 left the harder half open (**#211 B2**): a bare path with *no* verb
1492
+ (`/system/reboot`, `/quit`, `/terminal/cuu`) was still read as navigation and
1493
+ still cascaded, because the menu table is a floor and absence from it is not
1494
+ evidence of a command. B2 was parked rather than guessed at for exactly that
1495
+ reason — the only rule available would have had to invert the floor contract.
1496
+
1497
+ **#228 closes B2 for the published subset, without touching the floor.** The
1498
+ command axis is the missing *positive* evidence, so R12 refuses navigation into
1499
+ a path a table SAYS is a command rather than into one another table happens not
1500
+ to mention. `/system/reboot` no longer moves the context, so a statement after
1501
+ it resolves where it actually is; `verbsplit` and `pathresolve` agree, which
1502
+ they would otherwise now do *confidently* in opposite directions. What is left
1503
+ is the honest residue of both floors — `/terminal/cuu` is a real no-argument
1504
+ command MikroTik publishes nowhere — and it stays pinned as a KNOWN LIMIT
1505
+ fixture rather than assumed closed. The contradiction that #202 would have
1506
+ rendered is gone.
1507
+
1508
+ **The command axis also outranks the punctuation guess.** `splitRun`'s last
1509
+ rule reads the first space-separated token as the verb, which lands on the
1510
+ operand whenever the command itself was written slash-joined:
1511
+ `/system/gps/monitor once` put the verb on `once`, and
1512
+ `/interface ethernet reset-counters ether1` on `ethernet`. A published command
1513
+ names its own boundary, so the operand goes back to being an argument and
1514
+ `argsAt` moves with it. Over the frozen corpus plus the export stratum, 171 of
1515
+ 19,002 statement readings change and every one names a better token; Q16
1516
+ abstention moves 44.8% → **44.6%** dev, 46.0% holdout and 0.0% export unchanged,
1517
+ and exactly one document changes verdict (`unknown` → `false`, a pasted
1518
+ `/system/gps/monitor once` transcript — an abstention #207 recorded as correct
1519
+ *given no evidence*, which there now is). The command axis says which token is
1520
+ the verb; it says nothing about whether that verb mutates, so `write.ts`'s
1521
+ curated vocabularies are unchanged and `/system/reboot` still abstains.
1522
+
1523
+ `splitRun` itself is untouched again — both tables are consumed by the callers —
1524
+ so the ratified schema-free rule keeps pricing decision 3 on its own terms. Two
1525
+ places deliberately do *not* consult the catalog: scripting directives, where
1526
+ R10 was measured 2,088-to-18 and the seven root-level catalog commands all sit
1527
+ where R10 already puts them; and a bare-word head at the ROOT context, because
1528
+ those seven are ordinary English words and the corpus's pasted `import serial` /
1529
+ `import io` / `import os` would otherwise read as RouterOS `/import`. An
1530
+ *inherited* menu context is itself the evidence that the surrounding text is
1531
+ RouterOS, so a relative `stop [find …]` under `/container` does resolve.
1532
+
1533
+ **Importability was also measured, and is a weaker, narrower result than it
1534
+ first appears (#203 deliverable 4).** On CHR, none of the six root `/export`
1535
+ documents imports into a blank same-version CHR; per-menu exports do, and
1536
+ round-trip byte-exactly including continuation-wrapped comments. RouterOS
1537
+ `/import` is fail-fast, so it aborts at the first error and every later
1538
+ statement silently never runs. But **all four failure causes are semantic or
1539
+ device-state rejections, not syntax**: a duplicate object, a built-in object,
1540
+ a missing mandatory value, and a name collision. That was verified rather than
1541
+ assumed — each blocker statement was replayed through `:put [:parse …]` and
1542
+ `/console/inspect request=highlight` on both versions and **accepted every
1543
+ time**, with controls (`no-such-attr=`, an unterminated string, a nonexistent
1544
+ menu) correctly rejected. So these results carry **no** implication about
1545
+ whether `explain` can parse the forms, and they do not license claims about the
1546
+ export styles in general: the captures come from one synthetic configuration on
1547
+ CHR, which has a single NIC and no switch or wireless hardware, so its defconf
1548
+ and its available menus differ from any real device's. Treat the provenance
1549
+ label as **L1-emitted, L2-refuted for these captures on CHR**. Harness and
1550
+ per-document results: `.scratch/explain-lab-q203-import-verify.ts`;
1551
+ validity check: `.scratch/q203-blocker-validity.ts`.
1552
+
1553
+ **Non-blocking / deferred (not ratification-gating):** Q5 expression depth, Q7
1554
+ tokenizer corners, Q9 potential-command taxonomy, Q12 span-vocabulary draft —
1555
+ these refine the surface during implementation but did not gate ratification.
1556
+ They keep their homes in staging: **Q7** is now concrete as #201 (lexical
1557
+ boundaries — sigil spellings, escape validity, statement-start eligibility)
1558
+ and #199 (the shared scanner's `$[…]`-in-string blind spot), both raised by the
1559
+ Q13 promotion and both spanning already-promoted modules; **Q5** and **Q9** are
1560
+ phase-1 measurements; **Q12** is spec open item 2 and hardens in phase 4. The
1561
+ probe framework's disposition (reuse of the mutation suite, coordinate
1562
+ fixtures, and adversarial generators as product test fixtures) is tracked in
1563
+ issue #186 rather than carried into mainline.
1564
+
1565
+ ## Definition of done and staging
1566
+
1567
+ `designed` on the strength of this README. When implementation starts, offline
1568
+ examples gate via unit/fixture tests and each live cell advances to
1569
+ `CHR-passed` only when its `examples.md` entries run green via
1570
+ `bun run test:integration` (constitution: done definition). Suggested staging
1571
+ (sequence, not schedule):
1572
+
1573
+ - **Phase 0 — canonicalization grounding lab — COMPLETE (#185).** Experiments (corpus +
1574
+ CHR cross-checks against `:parse`/highlight) established what offline parsing
1575
+ can actually achieve: statement segmentation, block/scope resolution,
1576
+ sub-command path re-constitution, stateful-per-document path context, symbol
1577
+ scope, transport classification, write-shape, malformed-input recovery,
1578
+ coordinates, and stress invariants. Every ratification-gating question was
1579
+ answered with cited evidence; the findings are folded into the surface above
1580
+ and summarized in [Phase-0 ratification](#phase-0-ratification-185). This
1581
+ spec is ratified on that basis.
1582
+ - **Phase 0.5 — product contract fixtures — SUBSTANTIALLY COMPLETE (#186).**
1583
+ Promote a minimal, reviewed subset of the lab into product-owned tests; do
1584
+ not import or execute `.scratch/` code. One vertical-slice PR per lab suite,
1585
+ each landing an exported module under `src/explain/` plus frozen fixtures
1586
+ under `test/fixtures/explain/`. Shipped: Q15 coordinates (#188 →
1587
+ `coordinates.ts`), Q1 segmentation (#189 → `segment.ts`), Q2–Q4 path
1588
+ resolution (#191 → `pathresolve.ts`), Q17 single-pass container walker
1589
+ (#194 → `blocks.ts`, closing #190), Q6 verb/menu boundary (#193 →
1590
+ `verbsplit.ts`), Q16 write-shape tristate (#195 → `write.ts`), Q14 cascade /
1591
+ context-certainty (#197), Q13 symbol scope (#200 → `symbols.ts`). These pin
1592
+ the ratified thresholds: no confident command invented after a defect, zero
1593
+ false negatives on statically obvious writes, 100% coordinate invariants,
1594
+ deterministic/well-formed spans, bounded depth, no throw, and roughly linear
1595
+ scaling. **Remaining:** #192 (Q14 defect-*region* spans and the
1596
+ `ambiguous`/`unknown` verdict vocabulary — the verdict half wants the phase-1
1597
+ envelope). The lab framework and its two ~7.5 MB per-character highlight
1598
+ captures stay out of mainline (#186). What durable files CITE does not stay
1599
+ out: the frozen dev/holdout split, the Q8 REST probe capture, a stratified
1600
+ slice of the highlight streams, and the three device re-derivation probes
1601
+ are committed under `test/fixtures/explain/` and `scripts/probes/`.
1602
+ - **Phase 1 — offline core** — the grown canonicalizer: structure + gate
1603
+ verdict + per-statement resolution and transport classification +
1604
+ diagnostics (+ `--curl` rendering), and the CLI/MCP surface over it. Phase
1605
+ 0.5 shipped the analysis modules as library exports only; phase 1 composes
1606
+ them behind one entry point, adds the envelope and `src/cli/explain.ts`, and
1607
+ turns `examples.md` green. **Landing in four PRs:** #192a defect regions
1608
+ (#222), #202a composition + envelope (#224), **#202b the CLI surface + the
1609
+ offline examples**, then #202c-1 argument reading and #202c-2 transport
1610
+ classification + `--curl`, which close #202. Carried in with it: the lexical-boundary
1611
+ hardening (#201) and the shared-scanner substitution blind spot (#199), both
1612
+ found during Q13 and both spanning modules already promoted; plus the
1613
+ deferred lab questions Q5 (expression depth) and Q9 (potential-command
1614
+ taxonomy), which become focused tests as this surface hardens.
1615
+ - **Phase 2 — live probes** — highlight + `:parse` (+
1616
+ completion/child/syntax facets) over rest-api/native-api with the safety rules
1617
+ above, including the
1618
+ broad-query describe ladder, smart sizing, and CHR-backed anchors for Q6,
1619
+ Q8, Q10, and Q11.
1620
+ - **Phase 3 — facet polish** — `--complete`/`--schema` ergonomics, truncation
1621
+ counts/hints, `--full`.
1622
+ - **Phase 4 — library/LSP alignment** — export shape hardened against a real
1623
+ lsp-routeros-ts consumption spike (semantic tokens via the centrs span
1624
+ vocabulary + color map).
1625
+
1626
+ ## Decisions (2026-07-19 round) and remaining opens
1627
+
1628
+ Decided this round (details inline above; recorded in #90):
1629
+
1630
+ - **Option A** — one verb + facet flags; broad-query ladder
1631
+ (path → verbs, path/verb → args, `print` → `.proplist`); complexity of the
1632
+ refined scheme is an accepted risk.
1633
+ - **Smart sizing** over `--depth`; counts + truncation warning for large
1634
+ subtrees; `--full` (name TBD) as the only override. `--format` stays a
1635
+ rendering switch; colorized command breakdown is a rendering feature.
1636
+ - **No offline schema snapshot** — offline is the canonicalizer, full stop;
1637
+ schema/version truth comes from a live router (steered by tip) and
1638
+ docs/version history stays rosetta's domain.
1639
+ *Amended 2026-08-07 (#228).* The rejected candidate was the
1640
+ restraml/deep-inspect command tree: indirect, version-shaped schema data with
1641
+ substantial churn, and that reasoning still holds for it. The carve-out is
1642
+ narrower: offline ships **no schema** — no argument names, no types, no enums,
1643
+ no per-menu verb lists, no `.proplist` — but it does ship a generated
1644
+ **structure** table (path, kind, provenance, published applicability gate)
1645
+ unioned from pinned RouterOS inspect trees and MikroTik's published CLI
1646
+ Reference. Such a table may say whether a path is navigation or a command; it
1647
+ never describes what a command accepts, and absence from it abstains and never
1648
+ rejects. `src/explain/catalog.ts` is that table. Its **command axis is read**
1649
+ by `pathresolve.ts`, `verbsplit.ts` and `write.ts`. *Amended again by #235:*
1650
+ its **menu/settings kind is read too**, unioned with `menus.ts` — decisive for
1651
+ a relative bare path once its inherited context is applied, and for the
1652
+ absolute bare path `verbsplit.ts` used to call `ambiguous`. Presence still
1653
+ decides in one direction only; absence still abstains. It is still a static
1654
+ snapshot of vendor-published structure, which is why the decision is amended
1655
+ rather than argued around.
1656
+ - **No rosetta coupling for now** — at most steering tips; no calls, no
1657
+ artifacts, no maintained bindings.
1658
+ - **centrs-owned span vocabulary** with RouterOS-fidelity color mapping for
1659
+ LSP consumers; raw highlight classes are provenance/debug, not the surface.
1660
+ - **Grow `canonicalizeExecuteCommand`**; rosetta's `canonicalize.ts` is
1661
+ reference-only, divergence expected.
1662
+ - **MCP mirrors the CLI scheme** and result shape.
1663
+ - **Script-scale explain is in scope** — block/scope analysis and
1664
+ sub-command path re-constitution are target canonicalizer capabilities, and
1665
+ per-statement transport classification (execute vs api, `curl` rendering)
1666
+ is part of the base output.
1667
+
1668
+ Amendments from the PR review pass (Codex/CodeRabbit/Copilot on #184, same
1669
+ round):
1670
+
1671
+ - **Positional grammar is target-first** when live (`explain <router>
1672
+ '<input>'`); one positional = offline input. Matches every other
1673
+ router-taking command and the shared selection helpers.
1674
+ - **Transport classification is fail-closed** (`api-candidate` / `execute` /
1675
+ `unknown`); `curl` renders only from explicit, tested mapping rules.
1676
+ - **The gate verdict is reproduced verbatim**; explain-only mutation
1677
+ inference lives in `structure.containsWrite`, never in `writeShaped`.
1678
+ - **Per-fact provenance is an `evidence[]` table** with stable ids; target
1679
+ identity stays in `meta.target`.
1680
+ - **Envelope mirrors `check`** (`ok: true` + `data.verdict` for analyzed
1681
+ input); coordinate contract (byte offsets, exclusive `end`,
1682
+ original-document mapping for LSP) is explicit.
1683
+ - **`:parse` readout is transport-specific** (constitution); native API uses
1684
+ `/execute as-string` and returns the same parser text as REST/console, while
1685
+ omitting `as-string` returns only the job handle (Q11).
1686
+ - **MCP shape change is a deliberate pre-1.0 break**, migrated in one change.
1687
+
1688
+ Still open (implementation opens — none block ratification):
1689
+
1690
+ 1. Final flag names (`--full`, `--curl`, facet names) and the smart-sizing
1691
+ thresholds (what counts as "cheap", what triggers truncation).
1692
+ 2. The centrs span vocabulary itself: class list, mapping table from RouterOS
1693
+ highlight classes (and their colors), and how unknown/new upstream classes
1694
+ degrade. Phase 0 pinned the raw inputs (Q13's per-occurrence highlight-class
1695
+ corpus; the observed 19 classes on 7.23.2 plus drift on 7.24rc2) but the
1696
+ centrs-owned class list and color map are a **draft** (lab question Q12,
1697
+ non-ratification-gating); they harden in staging phase 4 against the real
1698
+ lsp-routeros-ts consumption spike.
1699
+ 3. Whether the live describe ladder needs result caching per target+version
1700
+ (probe cost vs freshness) — deferred to implementation evidence.
1701
+
1702
+ Resolved during phase 0:
1703
+
1704
+ - **First CLI→REST families (Q8).** Nine mapping rules are
1705
+ runtime-exercised (2xx on 7.23.2 and 7.24rc2): `add`→`PUT`, `print`→`GET`,
1706
+ `get`→`GET/<id>`, `set`→`PATCH/<id>`, `remove`→`DELETE/<id>`,
1707
+ `run(action)`→`POST/<path>/<command>` (demonstrated by `/ip/dns/cache flush`
1708
+ with an empty body, and read as the family it names, not as the one verb),
1709
+ and `proplist`/`query`→`POST/<path>/print`. Selector
1710
+ writes (`set`/`remove [find …]`), singleton-menu `set`, and non-CRUD actions
1711
+ fail closed to `execute`/`unknown` (no `curl`). Placeholder-host and
1712
+ credential-elision rendering details stay an implementation open; `curl` is
1713
+ REST-only (native-api has no curl analogue).
1714
+ - **Ratification question list (#185).** The phase-0 question list and the spec
1715
+ claims each gated were the deliverable of #185; every ratification-gating
1716
+ question is answered.