@tikoci/centrs 0.1.4 → 0.1.6

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 (68) hide show
  1. package/CHANGELOG.md +309 -0
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +8 -4
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +37 -8
  7. package/commands/api/examples.md +31 -1
  8. package/commands/execute/README.md +39 -10
  9. package/commands/execute/examples.md +79 -7
  10. package/commands/explain/README.md +1195 -155
  11. package/commands/explain/examples.md +99 -3
  12. package/commands/mcp/README.md +7 -4
  13. package/commands/mcp/examples.md +2 -2
  14. package/commands/terminal/examples.md +7 -1
  15. package/docs/CLI.md +2 -2
  16. package/docs/CONSTITUTION.md +72 -15
  17. package/docs/MATRIX.md +89 -52
  18. package/docs/errors/validation/syntax.md +29 -4
  19. package/package.json +13 -2
  20. package/src/api-fanout.ts +17 -4
  21. package/src/api.ts +254 -67
  22. package/src/cli/api.ts +1 -1
  23. package/src/cli/btest.ts +1 -1
  24. package/src/cli/explain.ts +1 -1
  25. package/src/cli/terminal.ts +1 -1
  26. package/src/core/envelope.ts +53 -0
  27. package/src/core/error-catalog.ts +2 -1
  28. package/src/core/execute-command.ts +148 -0
  29. package/src/core/inspect.ts +36 -4
  30. package/src/core/version.ts +4 -0
  31. package/src/core/yaml.ts +86 -0
  32. package/src/execute-fanout.ts +18 -4
  33. package/src/execute.ts +225 -244
  34. package/src/explain/arg-tokens.ts +62 -25
  35. package/src/explain/args.ts +398 -52
  36. package/src/explain/blocks.ts +54 -11
  37. package/src/explain/brace-tokens.ts +4 -3
  38. package/src/explain/catalog.ts +42 -44
  39. package/src/explain/coordinates.ts +159 -0
  40. package/src/explain/defects.ts +2 -1
  41. package/src/explain/escape-tokens.ts +77 -0
  42. package/src/explain/highlight-projection.ts +308 -0
  43. package/src/explain/menus.ts +9 -5
  44. package/src/explain/operator-tokens.ts +13 -7
  45. package/src/explain/operators.ts +27 -20
  46. package/src/explain/path-tokens.ts +1 -1
  47. package/src/explain/pathresolve.ts +358 -113
  48. package/src/explain/quoted-string.ts +110 -20
  49. package/src/explain/scope-brace.ts +175 -22
  50. package/src/explain/segment.ts +429 -21
  51. package/src/explain/separator.ts +187 -0
  52. package/src/explain/split-owner.ts +194 -0
  53. package/src/explain/string-tokens.ts +14 -4
  54. package/src/explain/symbol-values.ts +209 -173
  55. package/src/explain/symbols.ts +21 -1
  56. package/src/explain/transport.ts +17 -0
  57. package/src/explain/value-tokens.ts +15 -12
  58. package/src/explain/verbsplit.ts +165 -23
  59. package/src/explain/write.ts +47 -6
  60. package/src/explain.ts +522 -72
  61. package/src/index.ts +19 -2
  62. package/src/mcp/server.ts +2 -1
  63. package/src/offline-gate.ts +148 -0
  64. package/src/retrieve-fanout.ts +1 -1
  65. package/src/retrieve.ts +1 -59
  66. package/src/settings.ts +2 -1
  67. package/src/transfer-fanout.ts +1 -1
  68. package/src/transfer.ts +1 -1
package/src/api-fanout.ts CHANGED
@@ -39,6 +39,7 @@ import type {
39
39
  CentrsSuccessEnvelope,
40
40
  FanoutData,
41
41
  FanoutSummary,
42
+ ValidationTrace,
42
43
  } from "./core/envelope.ts";
43
44
  import {
44
45
  buildFanoutEnvelope,
@@ -49,6 +50,7 @@ import {
49
50
  runFanout,
50
51
  summarizeFanout,
51
52
  } from "./core/fanout.ts";
53
+ import { toYaml } from "./core/yaml.ts";
52
54
  import { CentrsError, serializeCentrsError } from "./errors.ts";
53
55
  import { promptForWriteConfirmation } from "./execute.ts";
54
56
  import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
@@ -61,7 +63,6 @@ import {
61
63
  type SelectionResolveInput,
62
64
  type TargetSelection,
63
65
  } from "./resolver/index.ts";
64
- import { toYaml } from "./retrieve.ts";
65
66
 
66
67
  /** Compact description of the selector that produced the fan-out. */
67
68
  export interface ApiSelectionSummary {
@@ -246,6 +247,11 @@ export async function apiFanout(
246
247
  await assertFanoutWriteConfirmed(request, requestSummary, method, expansion);
247
248
 
248
249
  const execute = internals.execute ?? runResolvedApi;
250
+ // Per-target validation trace. `execute` and `onExecuteError` are separate
251
+ // callbacks that share only the resolved request, so the request object is
252
+ // the key: without it a per-target error envelope would reconstruct
253
+ // `meta.validation` instead of reporting the gate that actually ran (GH#354).
254
+ const traces = new WeakMap<ResolvedApiRequest, ValidationTrace>();
249
255
  const sleep = internals.sleep ?? defaultFanoutSleep;
250
256
 
251
257
  const targets = await runFanout<
@@ -292,11 +298,18 @@ export async function apiFanout(
292
298
  target: memberTargetMeta(member),
293
299
  warnings: member.kind === "cdb" ? [...member.resolution.warnings] : [],
294
300
  }),
295
- execute: async (resolved, member) =>
296
- relabelAdhocMember(await execute(resolved), member),
301
+ execute: async (resolved, member) => {
302
+ const trace: ValidationTrace = {};
303
+ traces.set(resolved, trace);
304
+ return relabelAdhocMember(await execute(resolved, trace), member);
305
+ },
297
306
  onExecuteError: (resolved, member, error) =>
298
307
  relabelAdhocMember(
299
- buildApiErrorEnvelopeFromResolved(resolved, error),
308
+ buildApiErrorEnvelopeFromResolved(
309
+ resolved,
310
+ error,
311
+ traces.get(resolved),
312
+ ),
300
313
  member,
301
314
  ),
302
315
  sleep,
package/src/api.ts CHANGED
@@ -21,21 +21,26 @@ import type {
21
21
  CommonSettingsMeta,
22
22
  SettingSource as CoreSettingSource,
23
23
  EnvelopeValidationMeta,
24
+ EnvelopeValidationStage,
24
25
  Tip,
26
+ ValidationTrace,
25
27
  Warning,
26
28
  } from "./core/envelope.ts";
27
29
  import { buildTip } from "./core/envelope.ts";
28
30
  import {
29
- extractCompletionNames,
30
- inspectChildren,
31
+ inspectArgumentNames,
31
32
  inspectChildrenOrEmpty,
32
- inspectCompletions,
33
- isArgumentNode,
34
33
  isCommandNode,
35
34
  pathTokens,
36
35
  } from "./core/inspect.ts";
36
+ import { toYaml } from "./core/yaml.ts";
37
37
  import { CentrsError, serializeCentrsError } from "./errors.ts";
38
38
  import { promptForWriteConfirmation } from "./execute.ts";
39
+ import {
40
+ assertOfflineSyntax,
41
+ isOfflineGateRejection,
42
+ OFFLINE_GATE_SOURCE,
43
+ } from "./offline-gate.ts";
39
44
  import {
40
45
  type ApiVerb,
41
46
  createProtocolAdapter,
@@ -64,7 +69,6 @@ import {
64
69
  resolveTarget,
65
70
  toCoreSource,
66
71
  } from "./resolver/index.ts";
67
- import { toYaml } from "./retrieve.ts";
68
72
 
69
73
  export const apiOutputFormats = ["json", "yaml", "text"] as const;
70
74
  export type ApiOutputFormat = (typeof apiOutputFormats)[number];
@@ -97,7 +101,11 @@ export interface ApiRequest {
97
101
  rawQuery?: readonly string[];
98
102
  /** `--proplist` / `--attribute` property projection. */
99
103
  proplist?: readonly string[];
100
- /** `--raw`: bare RouterOS body passthrough; implies `--validate=false`. */
104
+ /**
105
+ * `--raw`: bare RouterOS body passthrough. DEFAULTS `--validate` to false; an
106
+ * explicit `validate` still wins, and its failures render through the `--raw`
107
+ * error contract (#154).
108
+ */
101
109
  raw?: boolean;
102
110
  /** `--listen`: native-api-only open-ended follow (later phase; rejected here). */
103
111
  listen?: boolean;
@@ -124,6 +132,23 @@ export interface ApiRequest {
124
132
  confirm?: (prompt: string) => Promise<boolean>;
125
133
  }
126
134
 
135
+ /**
136
+ * The `--raw` / `--validate` precedence for a summary built BEFORE (or instead
137
+ * of) full resolution — a usage error, or a fan-out member that never reached
138
+ * `resolveApiRequest`.
139
+ *
140
+ * Ambient sources are deliberately out of reach here: this runs where the CDB
141
+ * and config tiers have not been loaded, so it reports the two layers it CAN
142
+ * see. That is why it is a shared helper rather than an inlined expression —
143
+ * both call sites used to spell `request.raw ? false : …`, which contradicted
144
+ * the resolved request by reporting `validate: false` for an explicit
145
+ * `--raw --validate=true` (#154).
146
+ */
147
+ function summarizedValidate(request: ApiRequest): boolean {
148
+ if (request.validate !== undefined) return request.validate;
149
+ return !(request.raw ?? false);
150
+ }
151
+
127
152
  export interface NormalizedApiEndpoint {
128
153
  /** Canonical slash path with the leading `/rest` and any trailing id/`listen` removed. */
129
154
  path: string;
@@ -247,19 +272,25 @@ export async function apiEnvelope(
247
272
  env: Record<string, string | undefined> = Bun.env,
248
273
  ): Promise<ApiEnvelope> {
249
274
  let resolved: ResolvedApiRequest | undefined;
275
+ // The gate finishes inside `runResolvedApi`, but the error envelope is built
276
+ // out here after the throw has unwound past it. Without the trace, a
277
+ // `transport/*` or `routeros/*` fault on the request — both stages already
278
+ // settled — reported `/console/inspect` as the validator that failed.
279
+ const trace: ValidationTrace = {};
250
280
  try {
251
281
  resolved = await resolveApiRequest(request, env);
252
282
  await assertApiWriteConfirmed(request, resolved);
253
- return await runResolvedApi(resolved);
283
+ return await runResolvedApi(resolved, trace);
254
284
  } catch (error) {
255
285
  return resolved
256
- ? buildApiErrorEnvelopeFromResolved(resolved, error)
286
+ ? buildApiErrorEnvelopeFromResolved(resolved, error, trace)
257
287
  : buildApiErrorEnvelope(request, error, env);
258
288
  }
259
289
  }
260
290
 
261
291
  export async function runResolvedApi(
262
292
  resolved: ResolvedApiRequest,
293
+ trace: ValidationTrace = {},
263
294
  ): Promise<ApiSuccessEnvelope> {
264
295
  assertListenCapability(resolved);
265
296
  const backend = createProtocolAdapter({
@@ -279,18 +310,15 @@ export async function runResolvedApi(
279
310
  let tips: Tip[] = [];
280
311
  try {
281
312
  if (resolved.validate.value) {
282
- const result = await validateApiRequest(resolved, backend);
313
+ const result = await validateApiRequest(resolved, backend, trace);
283
314
  validation = result.validation;
284
315
  tips = result.tips;
285
316
  } else {
286
- validation = {
287
- enabled: false,
288
- source: "disabled",
289
- result: "skipped",
290
- syntax: false,
291
- semantic: false,
292
- };
317
+ validation = disabledApiValidationMeta(resolved);
293
318
  }
319
+ // Recorded BEFORE the request, so a transport drop or RouterOS fault below
320
+ // reports the gate that actually settled rather than a guess.
321
+ trace.validation = validation;
294
322
 
295
323
  const result = await backend.apiRequest(buildProtocolApiRequest(resolved));
296
324
  return {
@@ -574,19 +602,40 @@ export async function resolveApiRequest(
574
602
  )));
575
603
  const via = resolveApiProtocol(request, env, listen, cdbResolution, config);
576
604
  const format = resolveApiFormat(request, env, config);
577
- // `--raw` forces validation off (constitution: --raw waiver); otherwise the
578
- // inspect gate is on by default.
579
- const validate: ResolvedSetting<boolean> = raw
580
- ? { value: false, source: { kind: "cli", key: "--raw" } }
581
- : resolveBooleanSetting(
582
- request.validate,
583
- env,
584
- "CENTRS_VALIDATE",
585
- true,
586
- "validate",
587
- cdbResolution?.overrides.validate,
588
- config,
589
- );
605
+ // `--raw` is a PRECEDENCE LAYER, not an override (#154). It ranks below an
606
+ // explicit `--validate` and above every ambient source, so:
607
+ //
608
+ // --validate=true|false explicit CLI flag ← wins
609
+ // --raw → validate=false
610
+ // CENTRS_VALIDATE / CDB comment-kv / config
611
+ // default → validate=true
612
+ //
613
+ // It used to short-circuit the whole ladder, which silently discarded an
614
+ // explicit `--validate=true` — and env, CDB and config with it. `--raw
615
+ // --validate=true` is the intended way to debug `api` when centrs itself is
616
+ // suspect: the gate runs and a failure renders through `rawErrorPayload` as
617
+ // `{code,message}` on stderr with a nonzero exit, which is the `--raw` error
618
+ // contract for any other preflight failure. `--raw` still outranks the
619
+ // ambient sources rather than merely lowering the default, so it behaves the
620
+ // same on every machine regardless of a `CENTRS_VALIDATE` in the environment.
621
+ //
622
+ // The `--raw` arm must SHORT-CIRCUIT, not merely win a comparison: resolving
623
+ // the ambient layer first would parse `CENTRS_VALIDATE` even when `--raw`
624
+ // decides the value, so a malformed ambient value threw `settings/invalid-
625
+ // boolean` out of a call `--raw` had already settled — the same
626
+ // machine-dependence this layer exists to remove.
627
+ const validate: ResolvedSetting<boolean> =
628
+ raw && request.validate === undefined
629
+ ? { value: false, source: { kind: "cli", key: "--raw" } }
630
+ : resolveBooleanSetting(
631
+ request.validate,
632
+ env,
633
+ "CENTRS_VALIDATE",
634
+ true,
635
+ "validate",
636
+ cdbResolution?.overrides.validate,
637
+ config,
638
+ );
590
639
  const timeoutMs = resolveApiTimeout(
591
640
  request.timeout,
592
641
  env,
@@ -906,6 +955,9 @@ export function buildProtocolApiRequest(
906
955
  return request;
907
956
  }
908
957
 
958
+ /** The api device-stage validator: structured input is inspected, never `:parse`d. */
959
+ const API_DEVICE_STAGE_SOURCE = "/console/inspect request=child";
960
+
909
961
  interface ApiValidationResult {
910
962
  validation: EnvelopeValidationMeta;
911
963
  tips: Tip[];
@@ -913,22 +965,58 @@ interface ApiValidationResult {
913
965
 
914
966
  /**
915
967
  * The structured-input gate. Because the input is a path (not a CLI string) the
916
- * validator is `/console/inspect`, never `:put [:parse]`. Path existence via
917
- * `request=child`; add/set attribute validity via `request=child`+`completion`. A
968
+ * validator is `/console/inspect`, never `:put [:parse]`. Path existence and
969
+ * add/set attribute validity both come from `request=child` — command-level
970
+ * `request=completion` is unsafe on RouterOS 7.12.2 (`core/inspect.ts`). A
918
971
  * `/execute` script is a CLI string → `semantic: not-applicable`.
919
972
  */
920
973
  async function validateApiRequest(
921
974
  resolved: ResolvedApiRequest,
922
975
  backend: ProtocolAdapter,
976
+ trace: ValidationTrace = {},
923
977
  ): Promise<ApiValidationResult> {
924
978
  if (resolved.scriptMode) {
979
+ // `/execute` carries a CLI string, so `/console/inspect` has nothing to
980
+ // inspect — which until GH#354 meant the script was sent with NO gate at
981
+ // all (`syntax: false`, `semantic: "not-applicable"`, `result: "passed"`).
982
+ // The offline analyzer is the whole gate here: a syntax fault is now a
983
+ // byte span and no round trip, where before it reached the device.
984
+ const script = scriptOf(resolved);
985
+ const offline = script
986
+ ? assertOfflineSyntax(script, {
987
+ surface: "api /execute",
988
+ via: resolved.via.value,
989
+ })
990
+ : undefined;
991
+ trace.offlineVerdict = offline?.verdict;
992
+ const offlineReason =
993
+ offline === undefined
994
+ ? "no `script` field to analyze"
995
+ : offline.verdict === "warn"
996
+ ? "analyzer abstained on part of the input"
997
+ : undefined;
925
998
  return {
926
999
  validation: {
927
1000
  enabled: true,
928
- source: "/console/inspect (script, not-applicable)",
1001
+ source: offline === undefined ? "not-applicable" : OFFLINE_GATE_SOURCE,
929
1002
  result: "passed",
930
- syntax: false,
1003
+ syntax: offline !== undefined,
931
1004
  semantic: "not-applicable",
1005
+ stages: [
1006
+ {
1007
+ stage: "offline",
1008
+ source: OFFLINE_GATE_SOURCE,
1009
+ result: offline === undefined ? "skipped" : "passed",
1010
+ ...(offlineReason ? { reason: offlineReason } : {}),
1011
+ },
1012
+ {
1013
+ stage: "device",
1014
+ source: "/console/inspect request=child",
1015
+ result: "skipped",
1016
+ reason:
1017
+ "`/execute` is a CLI string, not a menu path; RouterOS re-validates it on the run",
1018
+ },
1019
+ ],
932
1020
  },
933
1021
  tips: [],
934
1022
  };
@@ -970,7 +1058,7 @@ async function validateApiRequest(
970
1058
  // Only reject when inspect actually surfaced the command's arguments; if it
971
1059
  // returns none, skip rather than false-reject a valid command.
972
1060
  if (isCommand && Object.keys(resolved.body).length > 0) {
973
- const available = await inspectApiAttributes(backend, tokens);
1061
+ const available = await inspectArgumentNames(backend, tokens);
974
1062
  if (available.length > 0) {
975
1063
  const requested = Object.keys(resolved.body);
976
1064
  const missing = requested.filter(
@@ -988,7 +1076,7 @@ async function validateApiRequest(
988
1076
  parameter: missing[0],
989
1077
  requestedAttributes: requested,
990
1078
  availableAttributes: available,
991
- validationSource: "/console/inspect request=child+completion",
1079
+ validationSource: "/console/inspect request=child",
992
1080
  },
993
1081
  });
994
1082
  }
@@ -1013,7 +1101,7 @@ async function validateApiRequest(
1013
1101
  }
1014
1102
 
1015
1103
  if (resolved.verb === "add" || resolved.verb === "set") {
1016
- const available = await inspectApiAttributes(backend, [
1104
+ const available = await inspectArgumentNames(backend, [
1017
1105
  ...tokens,
1018
1106
  resolved.verb,
1019
1107
  ]);
@@ -1033,14 +1121,14 @@ async function validateApiRequest(
1033
1121
  parameter: missing[0],
1034
1122
  requestedAttributes: requested,
1035
1123
  availableAttributes: available,
1036
- validationSource: "/console/inspect request=child+completion",
1124
+ validationSource: "/console/inspect request=child",
1037
1125
  },
1038
1126
  });
1039
1127
  }
1040
1128
  return {
1041
1129
  validation: {
1042
1130
  enabled: true,
1043
- source: "/console/inspect request=child+completion",
1131
+ source: "/console/inspect request=child",
1044
1132
  result: "passed",
1045
1133
  syntax: false,
1046
1134
  semantic: true,
@@ -1062,6 +1150,21 @@ async function validateApiRequest(
1062
1150
  };
1063
1151
  }
1064
1152
 
1153
+ /**
1154
+ * The `/execute` script body, when there is one to analyze.
1155
+ *
1156
+ * A missing or blank `script` is not this gate's error to raise:
1157
+ * {@link buildProtocolApiRequest} already rejects it with an actionable
1158
+ * `input/invalid-command` naming the three ways to pass one. Reporting a syntax
1159
+ * fault for an absent field would be the wrong diagnosis.
1160
+ */
1161
+ function scriptOf(resolved: ResolvedApiRequest): string | undefined {
1162
+ const script = resolved.body["script"];
1163
+ return typeof script === "string" && script.trim().length > 0
1164
+ ? script
1165
+ : undefined;
1166
+ }
1167
+
1065
1168
  function unknownPathError(resolved: ResolvedApiRequest): CentrsError {
1066
1169
  return new CentrsError({
1067
1170
  code: "validation/unknown-path",
@@ -1076,23 +1179,6 @@ function unknownPathError(resolved: ResolvedApiRequest): CentrsError {
1076
1179
  });
1077
1180
  }
1078
1181
 
1079
- async function inspectApiAttributes(
1080
- backend: ProtocolAdapter,
1081
- commandTokens: readonly string[],
1082
- ): Promise<string[]> {
1083
- const children = await inspectChildren(backend, commandTokens);
1084
- const childAttributes = children
1085
- .filter(isArgumentNode)
1086
- .map((child) => child.name)
1087
- .filter(
1088
- (name): name is string => typeof name === "string" && name.length > 0,
1089
- );
1090
- const completionRows = await inspectCompletions(backend, commandTokens);
1091
- return [
1092
- ...new Set([...childAttributes, ...extractCompletionNames(completionRows)]),
1093
- ].sort();
1094
- }
1095
-
1096
1182
  async function assertApiWriteConfirmed(
1097
1183
  request: ApiRequest,
1098
1184
  resolved: ResolvedApiRequest,
@@ -1299,7 +1385,7 @@ export function buildApiErrorEnvelope(
1299
1385
  : false,
1300
1386
  listen: request.listen ?? false,
1301
1387
  yes: request.yes ?? false,
1302
- validate: request.raw ? false : (request.validate ?? true),
1388
+ validate: summarizedValidate(request),
1303
1389
  raw: request.raw ?? false,
1304
1390
  format: resolveErrorFormat(request, env),
1305
1391
  },
@@ -1315,6 +1401,7 @@ export function buildApiErrorEnvelope(
1315
1401
  export function buildApiErrorEnvelopeFromResolved(
1316
1402
  resolved: ResolvedApiRequest,
1317
1403
  error: unknown,
1404
+ trace?: ValidationTrace,
1318
1405
  ): ApiErrorEnvelope {
1319
1406
  const centrsError = asApiError(error);
1320
1407
  return {
@@ -1322,17 +1409,117 @@ export function buildApiErrorEnvelopeFromResolved(
1322
1409
  error: serializeCentrsError(centrsError),
1323
1410
  warnings: [...resolved.warnings],
1324
1411
  tips: [],
1325
- meta: metaFromResolved(resolved, {
1326
- enabled: resolved.validate.value,
1327
- source: resolved.validate.value ? "/console/inspect" : "disabled",
1328
- result: resolved.validate.value ? "failed" : "skipped",
1412
+ meta: metaFromResolved(
1413
+ resolved,
1414
+ trace?.validation ??
1415
+ failedApiValidationMeta(resolved, centrsError, trace),
1416
+ ),
1417
+ };
1418
+ }
1419
+
1420
+ /**
1421
+ * `meta.validation` for an api error envelope. A stage-1 rejection opened no
1422
+ * connection, so the meta must not report `/console/inspect` as the validator
1423
+ * that failed (GH#354).
1424
+ */
1425
+ function failedApiValidationMeta(
1426
+ resolved: ResolvedApiRequest,
1427
+ error: CentrsError,
1428
+ trace?: ValidationTrace,
1429
+ ): EnvelopeValidationMeta {
1430
+ if (!resolved.validate.value) {
1431
+ return disabledApiValidationMeta(resolved);
1432
+ }
1433
+ if (isOfflineGateRejection(error)) {
1434
+ return {
1435
+ enabled: true,
1436
+ source: OFFLINE_GATE_SOURCE,
1437
+ result: "failed",
1329
1438
  syntax: false,
1330
- semantic: resolved.validate.value
1331
- ? resolved.scriptMode
1332
- ? "not-applicable"
1333
- : false
1334
- : false,
1335
- }),
1439
+ semantic: "not-applicable",
1440
+ stages: [
1441
+ { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
1442
+ {
1443
+ stage: "device",
1444
+ source: API_DEVICE_STAGE_SOURCE,
1445
+ result: "skipped",
1446
+ reason: "offline analysis rejected the script; no connection opened",
1447
+ },
1448
+ ],
1449
+ };
1450
+ }
1451
+ // `stages` is only asserted when a `validation/*` error identifies the device
1452
+ // stage as the rejecter. Everything downstream — a RouterOS fault on the
1453
+ // request, a transport drop — passed the gate and is served by
1454
+ // `trace.validation` before this function is ever reached.
1455
+ const deviceRejected = error.code.startsWith("validation/");
1456
+ return {
1457
+ enabled: true,
1458
+ source: "/console/inspect",
1459
+ result: "failed",
1460
+ syntax: false,
1461
+ semantic: resolved.scriptMode ? "not-applicable" : false,
1462
+ ...(deviceRejected
1463
+ ? { stages: [offlineApiStage(resolved, trace), deviceStageFailed()] }
1464
+ : {}),
1465
+ };
1466
+ }
1467
+
1468
+ /**
1469
+ * The offline-stage entry for an api gate that reached the device stage.
1470
+ *
1471
+ * Script mode is the only api shape stage 1 analyzes; a structured path request
1472
+ * is a path plus a body, not a CLI string, so its offline stage is `skipped`
1473
+ * with the reason spelled out rather than silently reported as passed.
1474
+ */
1475
+ function offlineApiStage(
1476
+ resolved: ResolvedApiRequest,
1477
+ trace?: ValidationTrace,
1478
+ ): EnvelopeValidationStage {
1479
+ if (!resolved.scriptMode) {
1480
+ return {
1481
+ stage: "offline",
1482
+ source: OFFLINE_GATE_SOURCE,
1483
+ result: "skipped",
1484
+ reason: "structured path request, not a CLI string (GH#354)",
1485
+ };
1486
+ }
1487
+ return {
1488
+ stage: "offline",
1489
+ source: OFFLINE_GATE_SOURCE,
1490
+ result: "passed",
1491
+ ...(trace?.offlineVerdict === "warn"
1492
+ ? { reason: "analyzer abstained on part of the input" }
1493
+ : {}),
1494
+ };
1495
+ }
1496
+
1497
+ function deviceStageFailed(): EnvelopeValidationStage {
1498
+ return {
1499
+ stage: "device",
1500
+ source: API_DEVICE_STAGE_SOURCE,
1501
+ result: "failed",
1502
+ };
1503
+ }
1504
+
1505
+ /**
1506
+ * `--validate=false` (including a bare `--raw`). Both stages are listed as
1507
+ * `skipped` rather than omitted, so a consumer reading `stages` never has to
1508
+ * special-case the disabled shape; `enabled: false` already carries the why.
1509
+ */
1510
+ function disabledApiValidationMeta(
1511
+ resolved: ResolvedApiRequest,
1512
+ ): EnvelopeValidationMeta {
1513
+ return {
1514
+ enabled: false,
1515
+ source: "disabled",
1516
+ result: "skipped",
1517
+ syntax: false,
1518
+ semantic: resolved.scriptMode ? "not-applicable" : false,
1519
+ stages: [
1520
+ { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
1521
+ { stage: "device", source: API_DEVICE_STAGE_SOURCE, result: "skipped" },
1522
+ ],
1336
1523
  };
1337
1524
  }
1338
1525
 
@@ -1525,7 +1712,7 @@ export function apiRequestSummaryFromRequest(
1525
1712
  write: parsed ? isApiMutating(parsed, normalized.path) : false,
1526
1713
  listen,
1527
1714
  yes: request.yes ?? false,
1528
- validate: request.raw ? false : (request.validate ?? true),
1715
+ validate: summarizedValidate(request),
1529
1716
  raw: request.raw ?? false,
1530
1717
  format: resolveErrorFormat(request, env),
1531
1718
  query: buildApiQuery(request),
package/src/cli/api.ts CHANGED
@@ -94,7 +94,7 @@ export const apiCommand: CliCommandMetadata = {
94
94
  {
95
95
  flag: "--raw",
96
96
  description:
97
- "Strip the envelope; emit bare RouterOS JSON. Implies `--validate=false`; does not imply `--yes`.",
97
+ "Strip the envelope; emit bare RouterOS JSON. Defaults `--validate` to false (an explicit `--validate=true` still runs the gate); does not imply `--yes`.",
98
98
  },
99
99
  {
100
100
  flag: "--yes",
package/src/cli/btest.ts CHANGED
@@ -23,10 +23,10 @@ import {
23
23
  renderBtestClientEnvelope,
24
24
  renderBtestServerEnvelope,
25
25
  } from "../btest.ts";
26
+ import { toYaml } from "../core/yaml.ts";
26
27
  import { asCentrsError, formatCentrsErrorText } from "../errors.ts";
27
28
  import { describeCentrs, parseDuration } from "../index.ts";
28
29
  import type { BtestDirection, BtestProtocol } from "../protocols/btest.ts";
29
- import { toYaml } from "../retrieve.ts";
30
30
  import {
31
31
  type CliCommandMetadata,
32
32
  type CliCommandOption,
@@ -92,7 +92,7 @@ export const explainCliCommand: CliCommandMetadata = {
92
92
  {
93
93
  flag: "--tokens",
94
94
  description:
95
- "Emit the total, gapless token partition behind `data.tokens[]` (provisional `class` until #264 B5: every byte not claimed by an analyzer is `unclassified`).",
95
+ "Emit the total, gapless token partition behind `data.tokens[]` (every byte not claimed by an analyzer is `unclassified`).",
96
96
  },
97
97
  {
98
98
  flag: "--format",
@@ -6,6 +6,7 @@
6
6
  * `process.*`); the core relay in `../terminal.ts` stays io-agnostic.
7
7
  */
8
8
 
9
+ import { toYaml } from "../core/yaml.ts";
9
10
  import {
10
11
  asCentrsError,
11
12
  CentrsError,
@@ -18,7 +19,6 @@ import {
18
19
  type TerminalIo,
19
20
  type TerminalRequest,
20
21
  } from "../index.ts";
21
- import { toYaml } from "../retrieve.ts";
22
22
  import {
23
23
  type CliCommandMetadata,
24
24
  expectValue,
@@ -111,6 +111,50 @@ export interface EnvelopeTargetMeta {
111
111
  sources?: Record<string, SettingSource>;
112
112
  }
113
113
 
114
+ /**
115
+ * One stage of the two-stage validation gate (GH#354).
116
+ *
117
+ * `offline` is the corpus-gated analyzer (`src/offline-gate.ts`): it opens no
118
+ * connection, so a `failed` offline stage means the device was never contacted.
119
+ * `device` is the RouterOS-side preflight — `:put [:parse …]` and/or
120
+ * `/console/inspect request=child`, depending on the command and transport.
121
+ *
122
+ * Stages appear in the order they run, and a stage that did not run is still
123
+ * listed with `result: "skipped"`. That is the point of the array: a reader must
124
+ * be able to tell "the device accepted it" from "the device was never asked".
125
+ */
126
+ export interface EnvelopeValidationStage {
127
+ stage: "offline" | "device";
128
+ /** Validator identity, e.g. `offline explain (canonicalizer)` or `:put [:parse]`. */
129
+ source: string;
130
+ result: "passed" | "failed" | "skipped";
131
+ /** Why a stage was skipped, when the reason is not simply `validate=false`. */
132
+ reason?: string;
133
+ }
134
+
135
+ /**
136
+ * Mutable sink that lets an error path report the validation that ACTUALLY ran.
137
+ *
138
+ * `runResolvedExecute` / `runResolvedApi` finish the gate and then do the real
139
+ * work, and the error envelope is built by a *different* function after the
140
+ * throw unwinds past them. Without this, a `routeros/*` fault on the run — which
141
+ * happens with both gate stages already green — got a reconstructed
142
+ * `result: "failed"` that erased the real outcome. The runner writes
143
+ * {@link ValidationTrace.validation} the moment the gate returns; the error
144
+ * builder prefers it over anything it could guess (GH#354, PR #356 review).
145
+ */
146
+ export interface ValidationTrace {
147
+ /** The completed gate result, set once validation finishes. */
148
+ validation?: EnvelopeValidationMeta;
149
+ /**
150
+ * The stage-1 verdict, recorded as soon as stage 1 returns — before stage 2
151
+ * can throw. `warn` is an abstention, and an error envelope that reported it
152
+ * as `passed` would be telling a consumer the analyzer read bytes it declined
153
+ * to judge.
154
+ */
155
+ offlineVerdict?: "pass" | "warn";
156
+ }
157
+
114
158
  export interface EnvelopeValidationMeta {
115
159
  enabled: boolean;
116
160
  /** Validator identity, e.g. `/console/inspect` or `:put [:parse]`. */
@@ -121,6 +165,15 @@ export interface EnvelopeValidationMeta {
121
165
  /** Semantic `/console/inspect` gate status, or not-applicable for script mode. */
122
166
  semantic?: boolean | "not-applicable";
123
167
  availableAttributes?: readonly string[];
168
+ /**
169
+ * Per-stage detail for the gate described in
170
+ * `docs/CONSTITUTION.md` → "Validation is the product".
171
+ *
172
+ * The flat `source` / `syntax` / `semantic` fields above stay the summary
173
+ * view and keep their meaning; this is the breakdown behind them. Absent on
174
+ * surfaces that have no RouterOS-command gate at all (`btest`).
175
+ */
176
+ stages?: readonly EnvelopeValidationStage[];
124
177
  }
125
178
 
126
179
  export interface EnvelopeTimingMeta {
@@ -554,7 +554,8 @@ export const errorCatalog: readonly ErrorCatalogEntry[] = [
554
554
  },
555
555
  {
556
556
  code: "validation/syntax",
557
- summary: "RouterOS rejected the command syntax during the :parse gate.",
557
+ summary:
558
+ "The command syntax was rejected by the validation gate — offline analysis, or the RouterOS :parse probe.",
558
559
  },
559
560
  {
560
561
  code: "validation/unknown-attribute",