@tikoci/centrs 0.1.1 → 0.1.3

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 (95) hide show
  1. package/CHANGELOG.md +107 -2
  2. package/GLOSSARY.txt +87 -0
  3. package/README.md +25 -9
  4. package/SECURITY.md +3 -3
  5. package/commands/AGENTS.md +3 -2
  6. package/commands/api/AGENTS.md +212 -0
  7. package/commands/api/README.md +177 -0
  8. package/commands/api/examples.md +428 -0
  9. package/commands/btest/README.md +56 -31
  10. package/commands/btest/examples.md +35 -10
  11. package/commands/check/README.md +371 -53
  12. package/commands/check/examples.md +390 -0
  13. package/commands/devices/README.md +177 -37
  14. package/commands/devices/examples.md +148 -0
  15. package/commands/execute/README.md +48 -7
  16. package/commands/execute/examples.md +90 -6
  17. package/commands/retrieve/README.md +37 -51
  18. package/commands/retrieve/examples.md +40 -9
  19. package/commands/settings/README.md +286 -22
  20. package/commands/settings/examples.md +408 -0
  21. package/commands/terminal/README.md +6 -0
  22. package/commands/transfer/README.md +32 -2
  23. package/commands/transfer/examples.md +66 -0
  24. package/docs/CONSTITUTION.md +40 -15
  25. package/docs/MATRIX.md +69 -142
  26. package/docs/errors/README.md +2 -1
  27. package/docs/errors/cdb/empty-selection.md +19 -0
  28. package/docs/errors/input/incomplete-gps.md +9 -0
  29. package/docs/errors/input/invalid-altitude.md +9 -0
  30. package/docs/errors/input/invalid-bbox.md +9 -0
  31. package/docs/errors/input/invalid-coordinate.md +9 -0
  32. package/docs/errors/input/invalid-radius.md +9 -0
  33. package/docs/errors/internal/settings-failed.md +9 -0
  34. package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
  35. package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
  36. package/docs/errors/settings/consequential-value.md +26 -0
  37. package/docs/errors/settings/invalid-boolean.md +21 -3
  38. package/docs/errors/settings/invalid-format.md +18 -3
  39. package/docs/errors/settings/invalid-integer.md +18 -3
  40. package/docs/errors/settings/invalid-timeout.md +20 -3
  41. package/docs/errors/settings/invalid-via.md +22 -3
  42. package/docs/errors/settings/reserved-key.md +29 -0
  43. package/docs/errors/settings/skip-env-file-active.md +25 -0
  44. package/docs/errors/settings/unknown-key.md +25 -0
  45. package/docs/errors/usage/fanout-not-supported.md +16 -0
  46. package/docs/errors/usage/invalid-method.md +23 -0
  47. package/package.json +9 -9
  48. package/src/api-fanout.ts +454 -0
  49. package/src/api.ts +1627 -0
  50. package/src/btest.ts +61 -1
  51. package/src/cli/api.ts +660 -0
  52. package/src/cli/btest.ts +20 -2
  53. package/src/cli/common.ts +75 -0
  54. package/src/cli/devices.ts +235 -6
  55. package/src/cli/discover.ts +3 -1
  56. package/src/cli/execute.ts +107 -6
  57. package/src/cli/mcp.ts +8 -1
  58. package/src/cli/retrieve.ts +135 -90
  59. package/src/cli/selection.ts +200 -0
  60. package/src/cli/settings.ts +286 -0
  61. package/src/cli/terminal.ts +22 -7
  62. package/src/cli/transfer.ts +171 -47
  63. package/src/cli.ts +29 -0
  64. package/src/core/error-catalog.ts +69 -0
  65. package/src/core/fanout.ts +301 -0
  66. package/src/core/inspect.ts +170 -0
  67. package/src/devices.ts +212 -20
  68. package/src/discover.ts +7 -2
  69. package/src/execute-fanout.ts +438 -0
  70. package/src/execute.ts +134 -70
  71. package/src/index.ts +166 -9
  72. package/src/mcp/config.ts +12 -2
  73. package/src/mcp/tools.ts +137 -2
  74. package/src/protocols/adapter.ts +357 -9
  75. package/src/protocols/btest-session.ts +279 -46
  76. package/src/protocols/btest.ts +6 -2
  77. package/src/protocols/index.ts +11 -5
  78. package/src/protocols/native-api.ts +223 -30
  79. package/src/resolver/cdb.ts +10 -2
  80. package/src/resolver/comment-kv.ts +58 -4
  81. package/src/resolver/config-file.ts +136 -0
  82. package/src/resolver/facts.ts +78 -0
  83. package/src/resolver/geo.ts +453 -0
  84. package/src/resolver/index.ts +46 -0
  85. package/src/resolver/mac.ts +12 -3
  86. package/src/resolver/selection.ts +304 -0
  87. package/src/resolver/settings.ts +54 -5
  88. package/src/resolver/target.ts +20 -2
  89. package/src/retrieve-fanout.ts +232 -268
  90. package/src/retrieve.ts +69 -94
  91. package/src/settings.ts +1078 -0
  92. package/src/terminal.ts +11 -1
  93. package/src/transfer-fanout.ts +527 -0
  94. package/src/transfer.ts +61 -11
  95. package/commands/stream/README.md +0 -70
@@ -1,59 +1,377 @@
1
1
  # check
2
2
 
3
- Probe a router and report which management paths are usable.
4
-
5
- Status: `not-started`. This file is a stub.
6
-
7
- ## Intent
8
-
9
- - TCP reachability per protocol (rest-api, native-api, ssh).
10
- - Auth check on whichever protocols answered.
11
- - Optional `--via` to limit the probe surface.
12
- - L2 probing (mac-telnet reachability / ARP) is **opt-in, off by default** —
13
- enabled by a MAC target, `--via mac-telnet`, or `--l2`. It needs L2 adjacency
14
- and is slower, so IP-level probing (rest/native/ssh) stays the default.
15
- - Output is the [standard envelope](../../docs/CONSTITUTION.md#result-envelope);
16
- `data` is a per-protocol probe result map.
17
- - `--fix` (opt-in, write-shaped): once `check` has authenticated over *one*
18
- protocol, it reads the live device and writes back through the `devices` layer
19
- (the only CDB writer), gated like any write (`--yes` / `confirm`). It refreshes
20
- two things in one authenticated pass:
21
- - **Connection metadata** — most usefully ports. Reached over MAC/mac-telnet,
22
- it reads `/ip/service` to learn the real REST/native-API ports and writes
23
- `port=` into the record's comment-kv so later calls connect directly.
24
- - **Derived facts** — `board`, `version`, `software-id`, plus the `updated=`
25
- stamp, from the same session (the same keys `devices add/set --check` and
26
- `discover --save` populate). These stay queryable-but-stale facts that never
27
- override a live read (see `commands/devices/README.md`, Derived facts).
28
-
29
- `--fix` is **always explicit**, never implicit; plain `check` (no `--fix`) is a
30
- pure read-only probe that writes nothing.
31
-
32
- ## Default service ports (grounding)
33
-
34
- centrs assumes RouterOS defaults when a port is not overridden (CDB `port=` /
35
- `--port`). Default `/ip/service` ports: `ftp` 21, `ssh` 22, `telnet` 23,
36
- `www` 80 (often moved, e.g. 7080), `www-ssl` 443, `winbox` 8291, `api` 8728,
37
- `api-ssl` 8729. (native API rides `api`/`api-ssl`; REST rides `www`/`www-ssl`.)
38
-
39
- Once `check` (or any command) has authenticated access over *one* protocol, it
40
- can read the live service map to discover non-default ports for *other*
41
- protocols — e.g. reach a box over SSH, then learn the REST port without the user
42
- specifying it, and "upgrade" the connection:
43
-
44
- ```routeros
45
- :put [:serialize to=json options=json.pretty \
46
- [ip/service/print detail as-value where !dynamic ]]
3
+ Run a battery of health/diagnostic **checks** against a router and return
4
+ findings (errors / warnings / tips) plus judgment-free, **allowlisted** metadata
5
+ in one envelope — a device *doctor* that also hands later `retrieve` / `execute`
6
+ / `api` calls the context they'd otherwise splunk for themselves.
7
+
8
+ Status: `designed` over `rest-api`, `native-api`, `ssh`, and `mac-telnet` (the
9
+ execute-capable transports the battery can ride); `snmp` and
10
+ `romon` / `winbox-terminal` stay `not-started`. See `docs/MATRIX.md` for the row.
11
+ Load-bearing rules — envelope, errors, settings precedence, identity, validation,
12
+ protocol selection, fan-out — live in
13
+ [`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md); the `(constitution: …)`
14
+ notes below point there rather than restating them.
15
+
16
+ ## What changed (this is a revision, not greenfield)
17
+
18
+ The prior stub scoped `check` as a **reachability probe** — "probe a router and
19
+ report which management paths are usable" — with the health data an afterthought
20
+ behind `--fix`. That reachability probe survives, but as *one phase* of a richer
21
+ command, and the CDB-write flag is now `--save` (it never touches the router; see
22
+ [`--save`](#--save)). The old L2 rationale the stub stated —
23
+
24
+ > L2 probing (mac-telnet reachability / ARP) is **opt-in, off by default** …
25
+ > It needs L2 adjacency and is slower, so IP-level probing (rest/native/ssh)
26
+ > stays the default.
27
+
28
+ is **preserved but re-grounded** (see [L2 timing experiment](#l2-timing-experiment)):
29
+ under the new model mac-telnet is not a parallel "L2 reachability" default — it is
30
+ a *transport the whole battery can ride* (via `execute`) when it is the only way
31
+ in (a bare-MAC target, or IP paths that failed). The opt-in default holds; the
32
+ experiment measures the cost that justifies it.
33
+
34
+ ## Where `check` sits
35
+
36
+ The built-in `check` battery is **read-only** — it never writes RouterOS. The
37
+ lone exception is `--check-script*`, a **trusted** escape hatch that *can* mutate
38
+ the router (see [Custom check script](#custom-check-script)); `--save` reconciles
39
+ centrs's own CDB, never the device (see [`--save`](#--save)).
40
+ Think `doctor`: a list of self-describing checks runs, each yields the standard
41
+ finding channels, and configuration lets a caller silence known-and-accepted
42
+ findings to reach a green result. Its probe machinery is deliberately reusable —
43
+ `discover`'s future IP-scan (#149) iterates `check` per host, and the metadata it
44
+ collects seeds TikTOML (#137).
45
+
46
+ ## Two phases
47
+
48
+ - **Reach** — TCP reachability per protocol, host→router connect **latency**
49
+ (RTT) captured from that same probe, an auth check on whichever answered, plus
50
+ **ARP presence** as an L2-adjacency fact. Creds are *optional to start*: a
51
+ no-cred `check` still reports reachability + latency and tips that credentials
52
+ are needed for the health phase. When auth fails entirely (or the target is a
53
+ bare MAC), reach enriches the profile **for free** from the MNDP cache —
54
+ advertised identity / version / platform / board / software-id — best-effort on
55
+ L2 adjacency (the codec `discover` already ships). This is the slice a subnet
56
+ sweep wants (`--preset reach`), not the full battery against a /24.
57
+ - **Health** — over the best working transport (constitution: protocol
58
+ selection), run the battery of RouterOS reads → findings + metadata. The reads
59
+ are ordinary `print`/`get` commands, so they ride `retrieve` / `api` on
60
+ rest/native **and** ride `execute` on ssh / mac-telnet — the battery is
61
+ transport-portable, which is exactly what lets mac-telnet serve as a slow
62
+ full-battery path. `resource` runs **first** (see
63
+ [Execution model](#execution-model)).
64
+
65
+ ## Checks are modules, not a conditional block
66
+
67
+ Each check is an independent, self-describing unit
68
+ (`{ id, title, description, tags, phase, cost, defaultOn, requires, dependsOn, run }`)
69
+ registered with an orchestrator. That registry is what powers `--list-checks`
70
+ (list **and explain** every check), clean extension (add a module, don't refactor
71
+ the flags), and the **progressive** default: expensive checks run only when a
72
+ cheaper one flags something.
73
+
74
+ - `cpu-detail` runs iff `resource` reported high CPU load.
75
+ - `certificates` runs iff `services` found `www-ssl` disabled or a cert near
76
+ expiry.
77
+ - Opt-off checks (`traffic`, `health`, `packages`, `connections`) never run
78
+ unless named (`--checks`) or pulled in by a preset.
79
+
80
+ "Smart" here is a first cut, expected to be tuned against real devices over time.
81
+ Because slow or hung probes are the main risk to usefulness, every check runs
82
+ under a per-check timeout (`--check-timeout`, default `8s`); a check that exceeds
83
+ it yields a `check/<id>/timed-out` finding and the battery continues with partial
84
+ results rather than hanging.
85
+
86
+ ## Execution model
87
+
88
+ The naive "15 checks → 15 calls" plan is fine over rest-api / native-api (calls
89
+ pipeline cheaply) but **excruciating** over ssh / mac-telnet, where every call
90
+ pays session setup. So the orchestrator batches per **round**, not per check:
91
+
92
+ 1. **Foundational read.** `resource` (version / arch / board) runs first — its
93
+ output is what the capability pre-flight needs (below).
94
+ 2. **Round 1.** All selected static reads are issued together. Over an
95
+ execute-transport centrs composes them into **one consolidated `:put`-JSON
96
+ script** (one `:serialize to=json` object — the `tojson` helper in the
97
+ grounding sample) that returns a single object in one session; over
98
+ rest/native they pipeline as individual calls.
99
+ 3. **Round 2 (only if triggered).** Progressive checks (`cpu-detail`,
100
+ `certificates`, …) whose predicate fired are collected and issued as **one**
101
+ additional batched round — never a round-trip per triggered check.
102
+
103
+ This keeps the mac-telnet full battery to ~two sessions instead of ~fifteen, and
104
+ the batched script only pulls **allowlisted fields** (proplist / explicit
105
+ attributes), which also bounds payload and avoids scraping secrets.
106
+
107
+ ## Findings, severity, and reaching "green"
108
+
109
+ Each check emits into the standard channels (constitution: result envelope),
110
+ plus a metadata block:
111
+
112
+ | Channel | Meaning for `check` |
113
+ | ---------- | -------------------------------------------------------------------------- |
114
+ | `error` | Blocks the check's purpose (no transport reachable, auth fails everywhere). |
115
+ | `warning` | A real anomaly about *this* device (telnet/ftp enabled, disk < 16 MB free, EOL version, cert expiring, SNMP community open to `0.0.0.0/0` or `::/0`). |
116
+ | `tip` | Advice, not a fault (enable `www-ssl`; reverse-proxy on 443, here's the mapping). |
117
+ | `metadata` | Facts, no verdict (arch, board, version + channel, ddns-name, license level, port map, latency, health sensors). Feeds `data.profile`. |
118
+
119
+ Finding codes are slash-namespaced (constitution: error model):
120
+ `check/<checkId>/<slug>` for errors/warnings, `tip/<checkId>/<slug>` for tips —
121
+ so a caller can suppress one finding, or a whole check's findings, by code.
122
+
123
+ **Verdict, `ok`, and exit code.** `check` computes `data.verdict` ∈ `pass` /
124
+ `tip` / `warn` / `fail` (the max finding severity after suppression). A run that
125
+ reached a transport and executed the battery is **`ok: true`** and always carries
126
+ `data` — an unhealthy device is a *finding*, not a command failure. Only a genuine
127
+ failure (no transport reachable, auth fails on every transport, orchestration
128
+ error) is `ok: false` (`error`, no data), matching the shared envelope.
129
+
130
+ Health gates **the exit code**, derived from the verdict vs `--fail-on` — a
131
+ per-command rule, like fan-out's granular codes:
132
+
133
+ - `--fail-on <error|warning|tip|never>` (default `error`) is the threshold.
134
+ - Exit `0` when the verdict is **below** it (at the default, warnings and tips are
135
+ footnotes → `0`); exit `2` when the run is `ok:true` but the verdict
136
+ **met/exceeded** it (reachable, but unhealthy per the chosen bar); exit `1` on
137
+ an `ok:false` command failure. `--fail-on never` forces exit `0`.
138
+
139
+ `--ignore <code|checkId>` (repeatable) suppresses matching findings so a
140
+ known-and-accepted condition stops counting toward the verdict and stops
141
+ cluttering output; `--ignore <checkId>` mutes a check's *findings* but keeps its
142
+ metadata, while `--skip <checkId>` doesn't run it at all. Suppression follows the
143
+ normal settings precedence (constitution): `--ignore` arg → `CENTRS_CHECK_IGNORE`
144
+ env → per-device CDB `check-ignore=` comment-kv. The CDB override lets a caller
145
+ durably accept findings without re-passing flags (adding `check-ignore=` to the
146
+ comment-kv allowlist is a follow-up against `commands/devices/README.md`).
147
+
148
+ ### Metadata is allowlisted, not raw
149
+
150
+ Built-in checks never dump raw `as-value` output into `metadata` — each maps a
151
+ **typed, allowlisted** subset, and credential-class fields are omitted or
152
+ redacted before entering the envelope (and, if an error ever mentions them,
153
+ listed in `redactable_fields`; constitution: error model). Concretely, from the
154
+ grounding sample: `/ip/cloud` keeps `dns-name` / `status` / `back-to-home-vpn`
155
+ but **drops** `vpn-wireguard-client-config`(-`qrcode`) and the VPN keys;
156
+ `/snmp/community` reports version / scope / count and **redacts the community
157
+ string** (it is a credential); `/certificate` keeps names / validity / fingerprint
158
+ and never key material. A custom check (below) owns its own output and is exempt
159
+ from this allowlist — that is part of why it is a trusted, gated path.
160
+
161
+ ## Check catalog (first cut)
162
+
163
+ Grounded in the RouterOS command set surveyed for this spec — each row's `reads`
164
+ column names its source command. `default`: `on` runs in the smart battery;
165
+ `trig` runs only when a dependency flags it; `off` runs only when named/preset.
166
+
167
+ | id | phase | default | emits | reads |
168
+ | -------------- | ------ | ------- | -------------- | ---------------------------------- |
169
+ | `reach` | reach | on | error/tip/meta | TCP + auth per protocol; host→router RTT; ARP presence; port map; MNDP-fact enrichment on auth-fail; tips `credentials-needed` when creds are absent |
170
+ | `resource` | health | on¹ | warn/tip/meta | `/system/resource` — version vs long-term, arch/board (**foundational, runs first**), cpu/mem/disk vs limits, disk < 16 MB, short uptime |
171
+ | `services` | health | on | warn/tip/meta | `/ip/service` — ports, `vrf`, `allowed-address`, disabled/invalid, `www-ssl` off, telnet/ftp on, reverse-proxy on 443, **CDB `port=` mismatch** |
172
+ | `cloud` | health | on | tip/meta | `/ip/cloud` — ddns-name, `ddns-enabled=auto` semantics; Back To Home fields may be absent on stock/trial CHR |
173
+ | `dns` | health | on | warn/meta | `/resolve` — warn (not error) if resolution fails |
174
+ | `ipv6` | health | on | tip/meta | `/ipv6/settings` — `disable-ipv6` |
175
+ | `snmp` | health | on | warn/tip/meta | `/snmp/community` — version/scope/count; community string **redacted**; warns on unrestricted enabled read scope (`0.0.0.0/0` or `::/0`) or default `public` with `security=none`; tips toward `snmp-community=` for future `retrieve --via snmp` |
176
+ | `certificates` | health | trig | warn/meta | `/certificate` when `www-ssl` off / near expiry |
177
+ | `cpu-detail` | health | trig | warn/meta | `/system/resource/cpu` when `resource` load is high |
178
+ | `routerboard` | health | on² | tip/meta | `/system/routerboard` — firmware vs routeros; absent on CHR |
179
+ | `license` | health | on³ | warn/meta | `/system/license` — `level`; matters most on CHR |
180
+ | `health` | health | off | meta-only | `/system/health` — variable shape, no verdicts |
181
+ | `packages` | health | off | meta | `/system/package` — installed/available + arch |
182
+ | `console` | health | off | tip/meta | `/system/console` — serial exists → tip when all paths fail |
183
+ | `traffic` | health | off | meta | `/interface/monitor-traffic` — **~10 s**, slow |
184
+ | `connections` | health | off | meta | firewall connection `count-only` |
185
+
186
+ ¹ foundational — always runs first. ² on for RouterBOARD hardware, auto-skipped
187
+ on CHR. ³ on for CHR, informational on hardware.
188
+
189
+ `--preset` bundles: `reach` (reach phase only), `health` (reach + on-by-default
190
+ health), `full` (every check including opt-off — slow), and `exposure` — a
191
+ **rollup** over the findings `services` / `certificates` / `snmp` already
192
+ produce. `exposure` reads **no extra paths** and does **no vulnerability or
193
+ misconfiguration scanning**; it is a filtered access-surface view (insecure
194
+ service enabled, weak/expiring cert, open community), named to avoid implying a
195
+ security audit it does not perform. `--tag <t>` filters by tag; `--deep` forces
196
+ triggered/enrichment checks; `--fast` suppresses progressive triggers.
197
+
198
+ ## Flags
199
+
200
+ | Flag | Behavior |
201
+ | ---- | -------- |
202
+ | `--preset <name>` | `reach` / `health` (default) / `exposure` / `full`. |
203
+ | `--checks <a,b,c>` | Run exactly these checks (+ their `dependsOn`). |
204
+ | `--skip <a,b,c>` | Run the selection except these. |
205
+ | `--tag <t>` | Restrict to checks carrying tag `t` (repeatable). |
206
+ | `--deep` / `--fast` | Force / suppress progressive (triggered) checks. |
207
+ | `--list-checks` | Print the catalog with descriptions; no network IO. |
208
+ | `--fail-on <severity>` | Verdict threshold that gates the **exit code** (`0`/`2`), not `ok`. Default `error`. |
209
+ | `--ignore <code\|checkId>` | Suppress matching findings (repeatable). |
210
+ | `--check-timeout <dur>` | Per-check timeout. Default `8s`. |
211
+ | `--save` | Reconcile the CDB from what was probed (write-shaped, gated). |
212
+ | `--check-script <str>` / `--check-script-file <file>` | Custom check; both **repeatable** (see below). Requires `--yes`. |
213
+ | `--via <protocol>` | Pin the battery transport. No silent downgrade (constitution). |
214
+ | `--l2` | Opt into L2 (mac-telnet / ARP) probing in the reach phase even when IP paths answer. Off by default — L2 is opt-in (see [L2 timing experiment](#l2-timing-experiment)); also implied by a bare-MAC target or `--via mac-telnet`. |
215
+ | `--group` / `--where` / `--all` / `--default` / `--concurrency` | Fan-out (see below). |
216
+ | target/auth | `--host`, `--port`, `--username`/`-u`, `--password`, `--insecure`, `--timeout`, `--resolve`, `--cdb-file`, `--cdb-password` — same resolver as `retrieve`/`execute`. |
217
+
218
+ ## Custom check script
219
+
220
+ The built-ins stay minimal on purpose; `--check-script` / `--check-script-file`
221
+ is the escape hatch for site- or policy-specific checks. Both are **repeatable**,
222
+ so an agent can compose checks from several sources; each is merged as its own
223
+ synthetic check (`check/custom/<n>`). A script must `:put [:serialize to=json …]`
224
+ an envelope-shaped result:
225
+
226
+ ```json
227
+ { "errors": [], "warnings": [], "tips": [], "metadata": {} }
47
228
  ```
48
229
 
49
- ## Open questions
230
+ **Trust and gating.** A custom script runs through `execute`'s script-mode syntax
231
+ gate (`:put [:parse]`; structured commands add inspect, but arbitrary script mode
232
+ does not). execute only classifies *structured* add/set/remove as write-shaped —
233
+ arbitrary **script mode is not caught**, so a custom script *can* mutate RouterOS.
234
+ It is therefore a **trusted** path: centrs requires `--yes` to run any
235
+ `--check-script*` and attaches a `check/custom-script/ran` audit warning to the
236
+ envelope. RouterOS-side ergonomics (outer block for `:local`, appending to arrays,
237
+ dashed-variable quoting, bool-vs-`yes|no` enums) are demonstrated in
238
+ `examples.md`.
239
+
240
+ ## Capability pre-flight (ties to #129)
241
+
242
+ RouterOS commands vary by version and arch, so a check must not blow up on a
243
+ device that lacks the path it reads. `resource` runs first and yields the **live**
244
+ version / arch / board; the orchestrator pre-flights each remaining check's
245
+ `requires` paths against that live value (falling back to the stored
246
+ `routeros_version` derived fact, or one batched `/console/inspect` when neither is
247
+ conclusive), and marks an unsupported check `ran:false` with
248
+ `skipReason:"unsupported-on-<version>"` — a graceful skip, not a raw protocol
249
+ error. Grounding is offline-first against restraml's historic `inspect.json`
250
+ (`jq`), CHR only to confirm. `check` is the first multi-consumer of the #129
251
+ pre-flight utility (alongside `transfer mkdir`).
252
+
253
+ ## `--save`
254
+
255
+ `--save` (was `--fix`; renamed because it never fixes the *router*) is the only
256
+ write-shaped path, and it writes **centrs's CDB, never RouterOS** — matching
257
+ `discover --save`. After the battery authenticates over one transport it
258
+ reconciles the resolved device's record in one pass (through the `devices` write
259
+ layer — the only CDB writer), gated like any write (`--yes` / confirm; `--force`
260
+ for a multi-record selection):
261
+
262
+ - **Connection metadata** — the resolved/`via` transport's `port=` (learned from
263
+ `services`) when non-default, so later calls connect directly. The CDB carries a
264
+ single `port=`, so the *full* per-protocol port map stays in `data.profile.ports`
265
+ (queryable); persisting per-protocol ports needs new `devices` keys — a tracked
266
+ follow-up, not this pass.
267
+ - **Derived facts** — `board`, `version`, `software-id`, `updated=` (the same
268
+ queryable-but-stale keys `devices add/set --check` and `discover --save`
269
+ populate; constitution: identity — derived facts never override a live read).
270
+ - **Newly-learned lookup keys** — `mac=` / `identity=` / `ip=` when the record
271
+ lacks them.
272
+
273
+ **SNMP boundary.** The `snmp` check *reports* a usable community (redacted) and
274
+ *tips* toward storing it — but `--save` does **not** silently persist a community,
275
+ because it is a credential and there may be several. Persisting it is an explicit,
276
+ separate act (`devices set <t> snmp-community=…`, consumed by the future
277
+ `retrieve --via snmp`); whether `--save --with-snmp` should opt into that is an
278
+ open item below.
279
+
280
+ Plain `check` (no `--save`) writes nothing.
281
+
282
+ ## Fan-out
283
+
284
+ `check` is a clean per-target pipeline, so it fans out like `retrieve` / `execute`
285
+ / `api` (constitution: target selection). Any selector (`--group` / `--where` /
286
+ `--all` / `--default`) or more than one positional switches to the locked
287
+ `FanoutData` envelope (`data = { summary, targets[] }`) with the granular
288
+ `0`/`2`/`1` exit code. Because an unhealthy device is now `ok:true` per target,
289
+ the fan-out exit code tracks **orchestration** (inner `ok`), not health — so the
290
+ `summary` aggregates per-target verdicts (a `verdicts` tally) and each target's
291
+ `data.verdict` stays in its inner envelope, which is how #149's IP-scan reads
292
+ per-host health. A multi-target `--save` is a write and needs `--yes`. This
293
+ per-host fan-out is exactly the machinery `discover`'s IP-scan (#149) will drive.
294
+
295
+ ## Output
296
+
297
+ Standard envelope (constitution: result envelope). `data` has three parts:
298
+
299
+ - `data.verdict` — `pass` / `tip` / `warn` / `fail`, the max finding severity after
300
+ suppression (gates the **exit code** via `--fail-on`; `ok` stays `true` when the
301
+ command ran).
302
+ - `data.checks[]` — per-check detail: `{ id, ran, skipReason?, verdict, findings[], metadata, timing }`.
303
+ - `data.profile` — a consolidated, flat **device profile** (board, arch, version,
304
+ channel, identity, ddns-name, license-level, `ports{}`, latency, reachable
305
+ transports …) assembled from the allowlisted check metadata. This is the
306
+ reusable "device-type" surface for #149 and #137. **Its field set is emitted
307
+ best-effort now but is not yet normative** — TikTOML (#137) / IP-scan (#149)
308
+ will freeze the schema; until then treat `data.profile` as additive and don't
309
+ hard-code its shape.
310
+
311
+ Check-level warnings/tips also roll up into the top envelope's `warnings[]` /
312
+ `tips[]` so a consumer that ignores `data.checks[]` still sees them.
313
+
314
+ ## L2 timing experiment
315
+
316
+ Required evidence for this spec (issue #136), re-scoped to the new model and
317
+ **run** on a real CHR via `@tikoci/quickchr` (mac-telnet uses the `socket-connect`
318
+ host-side L2 bridge that `discover` / mac-telnet cells already use — see
319
+ `commands/discover/README.md`, L2 validation policy):
320
+
321
+ - **Measured:** mac-telnet session setup + a representative health-battery round
322
+ (the consolidated `resource` + `services` script) vs the same battery over
323
+ rest-api / native-api on the same CHR.
324
+ - **Decided:** whether L2 is cheap enough to belong in the *default* reach sweep,
325
+ or stays opt-in (enabled by a MAC target, `--via mac-telnet`, or `--l2`).
326
+
327
+ Findings, run 2026-07-02 against quickchr CHR `7.23.1 (stable)` on local macOS
328
+ x86_64. Each row is five measured samples after one warm-up; each sample creates
329
+ a fresh adapter/session, matching the per-target battery cost.
330
+
331
+ | Path | Median | Samples |
332
+ | ---- | ------ | ------- |
333
+ | rest-api direct `resource` + `services` reads | 4 ms | 4, 4, 4, 4, 4 |
334
+ | native-api direct `resource` + `services` reads | 129 ms | 128, 130, 129, 131, 129 |
335
+ | rest-api consolidated execute script | 3 ms | 3, 3, 3, 3, 3 |
336
+ | native-api consolidated execute script | 130 ms | 130, 131, 131, 121, 118 |
337
+ | mac-telnet consolidated execute script | 10,888 ms | 10,888, 10,885, 10,932, 10,880, 10,895 |
338
+
339
+ Verdict: preserve L2 as **opt-in**. mac-telnet is useful as a rescue/full-battery
340
+ transport when it is the only way in, but a single consolidated session is ~10.9s;
341
+ a two-round progressive battery is roughly double that. That is not acceptable
342
+ as a default per-host cost for #149 subnet sweeps.
343
+
344
+ ## Definition of done
345
+
346
+ `designed` on the strength of this README (intent + flags). A protocol cell
347
+ advances to `CHR-passed` only when every example in `examples.md` runs green
348
+ against a real CHR through `bun run test:integration`. See
349
+ [`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md) for the full done rule.
50
350
 
51
- - `--fix` scope **decided** (above): connection metadata (ports) **plus** the
52
- derived facts (`board`/`version`/`software-id`/`updated=`), refreshed in one
53
- authenticated pass through the `devices` write layer; always explicit opt-in,
54
- never implicit. Plain `check` stays a read-only probe that writes nothing.
55
- - L2 probing opt-in **decided** (Intent above): off by default; enabled by a MAC
56
- target, `--via mac-telnet`, or `--l2`.
351
+ ## Open questions / decisions made this round
57
352
 
58
- Defer until `retrieve` is `CHR-passed`. `check` overlaps with discovery and is
59
- easier to design once name resolution lands.
353
+ - **Default breadth = smart battery** (reach + on-by-default health +
354
+ progressive), biased against slow/hang via `--check-timeout`. "Smart"
355
+ triggering thresholds are a first cut to tune against devices.
356
+ - **Red-verdict model** = `ok:true` with `data` retained; the health verdict
357
+ (`data.verdict`) gates the **exit code** (`0` pass / `2` unhealthy / `1` command
358
+ failure), like fan-out's granular codes. No envelope carve-out — the shared
359
+ discriminated-union envelope is unchanged.
360
+ - **Reach latency = host→router RTT** (+ ARP presence); router→internet health is
361
+ the `dns` check's job.
362
+ - **`exposure` (not `security`) is a rollup preset** — no extra reads, no
363
+ vulnerability/misconfiguration scanning; named honestly.
364
+ - **`--save` (not `--fix`)** — CDB-only, consistent with `discover --save`.
365
+ - **Metadata is allowlisted + secrets redacted**; batched execution over
366
+ execute-transports; `resource` foundational-first for capability gating; custom
367
+ scripts repeatable + `--yes`-gated + audited.
368
+ - **Suppression persists** per-device (CDB `check-ignore=`) and via env.
369
+ - **SNMP open threshold** includes unrestricted enabled read scopes
370
+ (`0.0.0.0/0` or `::/0`); stock CHR 7.23.1 exposes enabled `public` with
371
+ `security=none` and `addresses=::/0`, so that counts as a warning.
372
+ - **`data.profile` shape not yet normative** — deferred to #137/#149.
373
+ - **Naming is provisional** — flag/check/preset names here are for the concept;
374
+ rationalize holistically before code.
375
+ - **Still open:** whether `--save` should grow `--with-snmp` to persist a
376
+ community as `snmp-community=` comment-kv (and adding that key to the `devices`
377
+ allowlist); whether `--save` should also capture `ddns-name`.