@lexq/cli 3.0.0 → 3.0.2

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.
package/AGENTS.md CHANGED
@@ -46,7 +46,7 @@ Detailed documentation lives in the `skills/` directory. **Read the relevant ski
46
46
  2. **Always dry-run before publishing.** Run `lexq analytics dry-run` to validate.
47
47
  3. **Never modify a non-DRAFT version.** Check version status first. Clone if needed.
48
48
  4. **Always check facts before creating rules.** Run `lexq facts list` to know available keys and types.
49
- 5. **Use `--format json` for parsing.** This is the default. Don't change it.
49
+ 5. **Leave `--format` at its default, `json`, and parse the output.** A command that writes a file chooses the file's format with `--as`, not `--format`.
50
50
  6. **Copy full UUIDs from output.** Never guess or truncate IDs.
51
51
  7. **Memo is required for all deploy operations.** `publish`, `live`, `rollback`, `undeploy` — every one needs `--memo`.
52
52
  8. **Handle errors gracefully.** Check the error code and follow the action table in `lexq-shared/SKILL.md`.
@@ -175,8 +175,6 @@ The MCP toolset mirrors the CLI command inventory one-to-one.
175
175
 
176
176
  - CLI commands and MCP tools stay in lock-step — a new command lands with its MCP tool in the same PR, sharing
177
177
  the single api-client layer.
178
- - The hosted MCP server consumes this package from npm. Publish (`v*` tag) **before** redeploying it — CI cannot
179
- catch that ordering.
180
178
  - Releasing is two steps, in this order. Raise `version` in package.json inside the pull request, so a reviewer
181
179
  sees the number beside the change that justifies it. After it merges, push the matching `v<version>` tag — that
182
180
  tag is what publishes. A tag that disagrees with package.json is rejected before anything reaches npm.
package/CONTEXT.md CHANGED
@@ -26,7 +26,7 @@ simulation, deploy to production, and execute via REST API — all without modif
26
26
  └─────────────────────────────────────────────────────┘
27
27
  │
28
28
  ▼
29
- api.lexq.io (ALB, path-routed)
29
+ api.lexq.io
30
30
  │
31
31
  ┌──────────────┴──────────────┐
32
32
  ▼ ▼
@@ -38,8 +38,7 @@ simulation, deploy to production, and execute via REST API — all without modif
38
38
  LexQ Policy Engine
39
39
  ```
40
40
 
41
- The CLI exclusively calls the Partner API. Execution requests (runtime rule evaluation) use a separate path-routed
42
- endpoint.
41
+ The CLI calls only the Partner API. Your service evaluates rules through the Execution API.
43
42
 
44
43
  **Partner Base URL:** `https://api.lexq.io/api/v1/partners`
45
44
  **Execution Base URL:** `https://api.lexq.io/api/v1/execution`
@@ -77,7 +76,7 @@ Tenant
77
76
  | `DRAFT` | Editable. Rules can be added/modified. |
78
77
  | `ACTIVE` | Published. Locked — no modifications allowed. |
79
78
  | `ARCHIVED` | Superseded by a newer version. |
80
- | `EXPIRED` | Past `effectiveTo` date. Auto-transitioned by scheduler. |
79
+ | `EXPIRED` | Past its `effectiveTo` date. |
81
80
 
82
81
  ### Condition Operators
83
82
 
@@ -123,8 +122,7 @@ Publishing (DRAFT → ACTIVE) is a qualification event, not a deployment — it
123
122
  `BLOCKED` means the rule matched but lost conflict resolution — the round and reason are in `reasonCode` (
124
123
  `MUTEX_PRIORITY_LOST`, `GROUP_LIMIT_REACHED`, …). It is unrelated to the `BLOCK` action, whose rule stays `SELECTED`.
125
124
 
126
- `NOT_SELECTED` exists in the enum but the engine never produces it (reserved for effective-date filtering, not yet
127
- implemented).
125
+ `NOT_SELECTED` is part of the enum but is not returned today.
128
126
 
129
127
  ### Platform Event Types (for webhook subscriptions)
130
128
 
@@ -153,7 +151,7 @@ implemented).
153
151
  | **A/B Test** | Splitting traffic between the current live version and a test version by percentage. |
154
152
  | **Execution Trace** | Per-rule evaluation result showing whether the condition matched and what actions were generated. |
155
153
  | **Decision Trace** | Final disposition of a rule after conflict resolution (`SELECTED`, `BLOCKED`, …), with a `reasonCode` explaining why. |
156
- | **Snapshot Hash** | SHA-256 hash of a version's rule snapshot at deployment time. Used to verify integrity. |
154
+ | **Snapshot Hash** | A fingerprint of a version's rules, recorded at deployment and used to check that the deployed rules did not change. |
157
155
  | **Traffic Rate** | Percentage (1–99) of traffic routed to the A/B test version. |
158
156
  | **Webhook Subscription** | A platform event listener — receives notifications on deployment lifecycle events. Tenant-level. |
159
157
  | **Platform Event** | A lifecycle event emitted by the engine itself (`VERSION_PUBLISHED`, `DEPLOYED`, `ROLLED_BACK`, `UNDEPLOYED`, `DEPLOY_SCHEDULED`, `DEPLOY_SCHEDULE_CANCELED`). |
package/README.md CHANGED
@@ -224,13 +224,15 @@ lexq webhook-subscriptions list | get | save | delete | test
224
224
 
225
225
  | Flag | Description |
226
226
  |--------------------------|--------------------------------------------|
227
- | `--format <json\|table>` | Output format (default: `json`) |
227
+ | `--format <json\|table>` | How results are printed (default: `json`) |
228
228
  | `--api-key <key>` | Override stored API key |
229
229
  | `--base-url <url>` | Override API base URL |
230
230
  | `--dry-run` | Preview the HTTP request without executing |
231
231
  | `--verbose` | Show request/response details |
232
232
  | `--no-color` | Disable colored output |
233
233
 
234
+ `--format` only changes how results are printed. A command that writes a file, such as `lexq facts export`, chooses the file's format with `--as csv` or `--as json`.
235
+
234
236
  ## Configuration
235
237
 
236
238
  Config is stored at `~/.lexq/config.json`:
package/dist/index.js CHANGED
@@ -184,6 +184,14 @@ async function apiRequestWithMeta(method, path, options = {}) {
184
184
 
185
185
  // src/lib/output.ts
186
186
  import Table from "cli-table3";
187
+ import { InvalidArgumentError } from "commander";
188
+ var OUTPUT_FORMATS = ["json", "table"];
189
+ function parseOutputFormat(value) {
190
+ if (OUTPUT_FORMATS.includes(value)) return value;
191
+ throw new InvalidArgumentError(
192
+ `Use ${OUTPUT_FORMATS.join(" or ")}. The format of an exported file is chosen with --as.`
193
+ );
194
+ }
187
195
  function printJson(data) {
188
196
  console.log(stringifyJson(data, 2));
189
197
  }
@@ -394,6 +402,7 @@ var PURGE_AFTER_DAYS = 30;
394
402
  var DESCRIPTION_MAX_LENGTH = 255;
395
403
  var COMMIT_MESSAGE_MAX_LENGTH = 500;
396
404
  var SCHEDULE_TICK_MAX_MILLIS = 6e4;
405
+ var MAX_DRY_RUN_EVALUATIONS_PER_MINUTE = 60;
397
406
 
398
407
  // src/commands/groups.ts
399
408
  import "commander";
@@ -784,7 +793,8 @@ function registerVersionCommands(program) {
784
793
 
785
794
  A version is an immutable snapshot of rules within a policy group.
786
795
 
787
- Lifecycle: DRAFT → ACTIVE (publish) → ARCHIVED (superseded) | EXPIRED (past effectiveTo)
796
+ Lifecycle: DRAFT → ACTIVE (publish) → EXPIRED (past effectiveTo)
797
+ A version stays ACTIVE after a newer version goes live, so you can roll back to it.
788
798
 
789
799
  Commands:
790
800
  list List versions for a group
@@ -1346,6 +1356,7 @@ function escapeUnsafe(json) {
1346
1356
 
1347
1357
  // src/lib/export.ts
1348
1358
  import { writeFileSync as writeFileSync2 } from "fs";
1359
+ import { extname } from "path";
1349
1360
  var EXPORT_FORMATS = ExportFormat.map((name) => name.toLowerCase()).sort();
1350
1361
  function parseFormat(value) {
1351
1362
  if (typeof value === "string" && EXPORT_FORMATS.includes(value)) {
@@ -1355,6 +1366,25 @@ function parseFormat(value) {
1355
1366
  `Unsupported export format: ${String(value)}. Use ${EXPORT_FORMATS.join(" or ")}.`
1356
1367
  );
1357
1368
  }
1369
+ function fileFormat(command) {
1370
+ const opts = command.opts();
1371
+ const format = parseFormat(opts.as);
1372
+ const extension = typeof opts.output === "string" ? extname(opts.output).slice(1).toLowerCase() : "";
1373
+ let root = command;
1374
+ while (root.parent) root = root.parent;
1375
+ if (EXPORT_FORMATS.includes(extension)) {
1376
+ if (extension !== format) {
1377
+ console.error(
1378
+ `Note: ${opts.output} will hold ${format}. Pass --as ${extension} to write ${extension}.`
1379
+ );
1380
+ }
1381
+ } else if (root.getOptionValueSource("format") === "cli" && command.getOptionValueSource("as") !== "cli") {
1382
+ console.error(
1383
+ `Note: --format sets how results are printed, not the file. This file is ${format}; choose ${EXPORT_FORMATS.join(" or ")} with --as.`
1384
+ );
1385
+ }
1386
+ return format;
1387
+ }
1358
1388
  async function runExport(path, format, options) {
1359
1389
  const { output, params, ...clientOptions } = options;
1360
1390
  const body = await apiRequest("GET", path, {
@@ -1510,7 +1540,7 @@ ${data.length} unregistered`);
1510
1540
  STRING types take --allowed-values. NUMBER types take all three.
1511
1541
  BOOLEAN takes none — it has only two values to begin with.
1512
1542
  For list types the value domain applies to each element.
1513
- Leave them out for no value domain, which is how facts behaved before.
1543
+ Leave them out for no value domain.
1514
1544
  `
1515
1545
  ).action(async (opts) => {
1516
1546
  try {
@@ -1621,10 +1651,10 @@ ${warning}
1621
1651
  $ lexq facts export --as json --output facts.json
1622
1652
  $ lexq facts export --keyword user --as json
1623
1653
  `
1624
- ).action(async (opts) => {
1654
+ ).action(async (opts, command) => {
1625
1655
  try {
1626
1656
  const globalOpts = program.opts();
1627
- await runExport("schema/facts/export", parseFormat(opts.as), {
1657
+ await runExport("schema/facts/export", fileFormat(command), {
1628
1658
  apiKey: globalOpts.apiKey,
1629
1659
  baseUrl: globalOpts.baseUrl,
1630
1660
  dryRun: globalOpts.dryRun,
@@ -2072,8 +2102,8 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2072
2102
  "after",
2073
2103
  dedent7`
2074
2104
 
2075
- The version must be ACTIVE with a future effective start date; the system
2076
- deploys it automatically at that time (within one scheduler tick, ≤${SCHEDULE_TICK_MAX_MILLIS / 1e3}s).
2105
+ The version must be ACTIVE with a future effective start date; it is
2106
+ deployed automatically within ${SCHEDULE_TICK_MAX_MILLIS / 1e3} seconds after that time.
2077
2107
  That time is the version's effectiveFrom, set on the DRAFT before publishing;
2078
2108
  this command takes no time of its own.
2079
2109
  One pending schedule per group. Manual deploy/rollback/undeploy, starting
@@ -2208,13 +2238,13 @@ import "commander";
2208
2238
  import dedent8 from "dedent";
2209
2239
 
2210
2240
  // src/lib/dataset-upload.ts
2211
- import { extname } from "path";
2241
+ import { extname as extname2 } from "path";
2212
2242
  var DATASET_CONTENT_TYPES = {
2213
2243
  ".csv": "text/csv",
2214
2244
  ".json": "application/json"
2215
2245
  };
2216
2246
  function datasetContentType(filename) {
2217
- return DATASET_CONTENT_TYPES[extname(filename).toLowerCase()];
2247
+ return DATASET_CONTENT_TYPES[extname2(filename).toLowerCase()];
2218
2248
  }
2219
2249
  function uploadDataset(bytes, filename, options) {
2220
2250
  const form = new FormData();
@@ -2226,6 +2256,9 @@ function uploadDataset(bytes, filename, options) {
2226
2256
  });
2227
2257
  }
2228
2258
 
2259
+ // src/lib/dry-run-limit.ts
2260
+ var DRY_RUN_LIMIT_NOTE = `Not billed and not counted toward the plan's usage. Dry runs, dry run compares, and single-decision replays share a limit of ${MAX_DRY_RUN_EVALUATIONS_PER_MINUTE} evaluations per minute for each organization; a compare counts as two. Past the limit, a call fails with 429 (C-007); wait a minute before trying again.`;
2261
+
2229
2262
  // src/commands/analytics.ts
2230
2263
  function registerAnalyticsCommands(program) {
2231
2264
  const analytics = program.command("analytics").description("Dry run, simulation, and requirements").addHelpText(
@@ -2255,6 +2288,8 @@ function registerAnalyticsCommands(program) {
2255
2288
  $ lexq analytics dry-run --version-id <vid> --file test-input.json
2256
2289
 
2257
2290
  The request body must include a "facts" object. Use --debug for execution traces
2291
+
2292
+ ${DRY_RUN_LIMIT_NOTE}
2258
2293
  `
2259
2294
  ).action(async (opts) => {
2260
2295
  try {
@@ -2293,6 +2328,8 @@ function registerAnalyticsCommands(program) {
2293
2328
  }'
2294
2329
 
2295
2330
  Shows side-by-side which rules matched and what actions fired for each version.
2331
+
2332
+ ${DRY_RUN_LIMIT_NOTE}
2296
2333
  `
2297
2334
  ).action(async (opts) => {
2298
2335
  try {
@@ -2604,10 +2641,10 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2604
2641
  $ lexq analytics simulation export --id <simId> --as csv --output results.csv
2605
2642
  $ lexq analytics simulation export --id <simId> --as json
2606
2643
  `
2607
- ).action(async (opts) => {
2644
+ ).action(async (opts, command) => {
2608
2645
  try {
2609
2646
  const globalOpts = program.opts();
2610
- await runExport(`analytics/simulations/${opts.id}/export`, parseFormat(opts.as), {
2647
+ await runExport(`analytics/simulations/${opts.id}/export`, fileFormat(command), {
2611
2648
  apiKey: globalOpts.apiKey,
2612
2649
  baseUrl: globalOpts.baseUrl,
2613
2650
  dryRun: globalOpts.dryRun,
@@ -2666,7 +2703,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2666
2703
  $ lexq analytics dataset template --group-id <gid> --version-id <vid> --output template.csv
2667
2704
  $ lexq analytics dataset template --group-id <gid> --version-id <vid> --as json
2668
2705
  `
2669
- ).action(async (opts) => {
2706
+ ).action(async (opts, command) => {
2670
2707
  try {
2671
2708
  const globalOpts = program.opts();
2672
2709
  const config = loadConfig();
@@ -2675,7 +2712,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2675
2712
  if (!apiKey) {
2676
2713
  throw new Error('Not authenticated. Run "lexq auth login" first.');
2677
2714
  }
2678
- const fmt = parseFormat(opts.as);
2715
+ const fmt = fileFormat(command);
2679
2716
  const url = new URL(
2680
2717
  `analytics/groups/${opts.groupId}/versions/${opts.versionId}/dataset-template`,
2681
2718
  baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"
@@ -2749,7 +2786,7 @@ function registerProfileCommands(program) {
2749
2786
  per-rule p50s within the group. Absolute ms thresholds are intentionally
2750
2787
  not supported. Each percentile is withheld (–) unless n×(1−q) ≥ ${TAIL_MIN_OBS} —
2751
2788
  p50 from n ≥ ${PERCENTILE_MIN_N.p50}, p95 from n ≥ ${PERCENTILE_MIN_N.p95}, p99 from n ≥ ${PERCENTILE_MIN_N.p99}. TOTAL is recorded
2752
- on every call, rule detail from a deterministic ${PROFILE_SAMPLE_PERMILLE / 10}% sample.
2789
+ on every call, rule detail from a ${PROFILE_SAMPLE_PERMILLE / 10}% sample of calls.
2753
2790
 
2754
2791
  Examples:
2755
2792
  $ lexq profile <groupId>
@@ -2807,7 +2844,9 @@ function registerProfileCommands(program) {
2807
2844
  console.log(`baseline ${b.phase.padEnd(9)}: ${base}`);
2808
2845
  }
2809
2846
  if (data.droppedRows > 0) {
2810
- console.log(`\u26A0 droppedRows=${data.droppedRows} (corrupt histogram rows skipped)`);
2847
+ console.log(
2848
+ `\u26A0 droppedRows=${data.droppedRows} (some samples could not be read and are not included)`
2849
+ );
2811
2850
  }
2812
2851
  printTable(
2813
2852
  ["Rule", "Phase", "n", "p50(ms)", "p95(ms)", "p99(ms)", "\xD7base", "Flagged"],
@@ -3007,8 +3046,10 @@ function registerReplayCommands(program) {
3007
3046
  "after",
3008
3047
  dedent11`
3009
3048
 
3010
- Free of charge (TPS throttle only). Returns decisionChanged, effect
3011
- changes, fired rules on both sides, and a determinism verdict.
3049
+ Returns decisionChanged, effect changes, fired rules on both sides,
3050
+ and a determinism verdict.
3051
+
3052
+ ${DRY_RUN_LIMIT_NOTE}
3012
3053
 
3013
3054
  Example:
3014
3055
  $ lexq replay decision --trace-id <tid> --version-id <vid>
@@ -3129,8 +3170,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
3129
3170
  "after",
3130
3171
  dedent11`
3131
3172
 
3132
- Only COMPLETED jobs can be exported. A running job has no full result yet, and a
3133
- partial one reads as the whole thing on the receiving end.
3173
+ Only COMPLETED jobs can be exported.
3134
3174
 
3135
3175
  The two formats carry different things. CSV holds the effect blast radius, whose
3136
3176
  columns are fixed. JSON holds the changed samples and action parameters, which
@@ -3140,10 +3180,10 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
3140
3180
  $ lexq replay export --id <jobId> --as csv --output blast-radius.csv
3141
3181
  $ lexq replay export --id <jobId> --as json
3142
3182
  `
3143
- ).action(async (opts) => {
3183
+ ).action(async (opts, command) => {
3144
3184
  try {
3145
3185
  const globalOpts = program.opts();
3146
- await runExport(`replay/jobs/${opts.id}/export`, parseFormat(opts.as), {
3186
+ await runExport(`replay/jobs/${opts.id}/export`, fileFormat(command), {
3147
3187
  apiKey: globalOpts.apiKey,
3148
3188
  baseUrl: globalOpts.baseUrl,
3149
3189
  dryRun: globalOpts.dryRun,
@@ -3264,7 +3304,7 @@ function registerLogCommands(program) {
3264
3304
  "after",
3265
3305
  dedent13`
3266
3306
 
3267
- System failure logs (DLQ) for background tasks — platform event webhooks and scheduled deployments.
3307
+ Failure logs for platform event webhooks and scheduled deployments that failed.
3268
3308
 
3269
3309
  Commands:
3270
3310
  list List failure logs with filters
@@ -3684,7 +3724,7 @@ function registerAnalyticsTools(server, callApi) {
3684
3724
  title: "Dry Run",
3685
3725
  annotations: READ_ONLY,
3686
3726
  description: dedent15`
3687
- Execute a single dry run against a version. Tests how rules evaluate given input facts without side effects.
3727
+ Execute a single dry run against a version. Tests how rules evaluate given input facts without side effects. ${DRY_RUN_LIMIT_NOTE}
3688
3728
 
3689
3729
  Returns:
3690
3730
  inputFacts — normalized input facts
@@ -3713,7 +3753,7 @@ function registerAnalyticsTools(server, callApi) {
3713
3753
  title: "Dry Run Compare",
3714
3754
  annotations: READ_ONLY,
3715
3755
  description: dedent15`
3716
- Compare dry run results between two versions using the same input facts. Useful for validating changes.
3756
+ Compare dry run results between two versions using the same input facts. Useful for validating changes. ${DRY_RUN_LIMIT_NOTE}
3717
3757
 
3718
3758
  Returns:
3719
3759
  resultA / resultB — full DryRunResponse for each version
@@ -3753,8 +3793,9 @@ function registerAnalyticsTools(server, callApi) {
3753
3793
  description: dedent15`
3754
3794
  Start an Impact Simulation against historical, uploaded, or inline data.
3755
3795
 
3756
- Starting checks the plan's simulation usage limit. The records a simulation processes
3757
- count toward that usage when it completes or is canceled.
3796
+ Every record a simulation processes counts toward the plan's simulation usage, including
3797
+ records processed before a cancel. When the plan's limit is reached, a new simulation
3798
+ cannot start.
3758
3799
 
3759
3800
  dataset.type and dataset.source are BOTH required, and must be paired:
3760
3801
  HISTORICAL → source EXECUTION_LOGS, with dataset.from / dataset.to (yyyy-MM-dd, whole days
@@ -3863,7 +3904,7 @@ function registerAnalyticsTools(server, callApi) {
3863
3904
  annotations: ADDITIVE,
3864
3905
  description: dedent15`
3865
3906
  Upload inline CSV or JSON content as a simulation dataset.
3866
- The content is uploaded to S3 and a path is returned in the "path" field.
3907
+ The content is stored as a dataset, and its path is returned in the "path" field.
3867
3908
 
3868
3909
  To use the returned path in lexq_simulation_start, set:
3869
3910
  dataset: { "type": "UPLOADED", "source": "S3_BUCKET", "path": "<returned path>" }
@@ -4657,7 +4698,7 @@ function registerDeployTools(server, callApi) {
4657
4698
  {
4658
4699
  title: "Schedule Deployment",
4659
4700
  annotations: DESTRUCTIVE,
4660
- description: "Schedule an ACTIVE version with a future effective start date to auto-deploy at that time (Scheduled Deployment). The time is the version's effectiveFrom, set on the DRAFT before publishing; this call takes no time of its own. One pending schedule per group; manual deploy/rollback/undeploy, starting an A/B test, or archiving the group cancels it. The snapshot hash is sealed at scheduling and re-verified at execution (fail-closed).",
4701
+ description: "Schedule an ACTIVE version with a future effective start date to auto-deploy at that time (Scheduled Deployment). The time is the version's effectiveFrom, set on the DRAFT before publishing; this call takes no time of its own. One pending schedule per group; manual deploy/rollback/undeploy, starting an A/B test, or archiving the group cancels it. A scheduled deployment uses exactly the rules that were scheduled; if they do not match when it runs, it fails with HASH_MISMATCH and nothing is deployed.",
4661
4702
  inputSchema: z8.object({
4662
4703
  groupId: z8.string().uuid().describe("Policy group ID"),
4663
4704
  versionId: z8.string().uuid().describe("ACTIVE version ID with a future effective start date"),
@@ -4785,7 +4826,7 @@ function registerProfileTools(server, callApi) {
4785
4826
  {
4786
4827
  title: "Group Latency Profile",
4787
4828
  annotations: READ_ONLY,
4788
- description: "Per-rule latency profile of a policy group over a time window: group TOTAL distribution split by cache state (HIT = compiled ruleset cache hit, MISS = deep-load + compile), a per-rule CONDITION/ACTION percentile table, and slow-rule flags. " + RELATIVE_THRESHOLD + ` Every percentile is accompanied by its sample count n; a percentile is withheld (null) unless n\xD7(1\u2212q) \u2265 ${TAIL_MIN_OBS} (p50 needs n \u2265 ${PERCENTILE_MIN_N.p50}, p95 n \u2265 ${PERCENTILE_MIN_N.p95}, p99 n \u2265 ${PERCENTILE_MIN_N.p99} \u2014 display gate, separate from the n \u2265 ${MIN_SAMPLES} judgment gate). Baselines report INSUFFICIENT_COHORT when fewer than ${MIN_COHORT_SIZE} rules qualify. Rule detail comes from a deterministic ${PROFILE_SAMPLE_PERMILLE / 10}% sample of calls; TOTAL is recorded for every call. Defaults: last ${PROFILE_DEFAULT_WINDOW_HOURS}h, live version, cacheState HIT.`,
4829
+ description: "Per-rule latency profile of a policy group over a time window: group TOTAL distribution split by cacheState (HIT = calls on a version already prepared for execution, MISS = calls that had to prepare the version first and run slower), a per-rule CONDITION/ACTION percentile table, and slow-rule flags. " + RELATIVE_THRESHOLD + ` Every percentile is accompanied by its sample count n; a percentile is withheld (null) unless n\xD7(1\u2212q) \u2265 ${TAIL_MIN_OBS} (p50 needs n \u2265 ${PERCENTILE_MIN_N.p50}, p95 n \u2265 ${PERCENTILE_MIN_N.p95}, p99 n \u2265 ${PERCENTILE_MIN_N.p99} \u2014 display gate, separate from the n \u2265 ${MIN_SAMPLES} judgment gate). Baselines report INSUFFICIENT_COHORT when fewer than ${MIN_COHORT_SIZE} rules qualify. Rule detail comes from a ${PROFILE_SAMPLE_PERMILLE / 10}% sample of calls; TOTAL is recorded for every call. Defaults: last ${PROFILE_DEFAULT_WINDOW_HOURS}h, live version, cacheState HIT.`,
4789
4830
  inputSchema: z9.object({
4790
4831
  groupId: z9.string().uuid().describe("Policy group ID"),
4791
4832
  versionId: z9.string().uuid().optional().describe("Version to inspect (default: live version)"),
@@ -4830,7 +4871,7 @@ function registerReplayTools(server, callApi) {
4830
4871
  {
4831
4872
  title: "Replay a Decision",
4832
4873
  annotations: READ_ONLY,
4833
- description: "Re-evaluate a past execution (traceId) against a candidate version and return the decision diff (decisionChanged, effect changes, fired rules) plus a determinism verdict. Synchronous and free of charge (TPS throttle only). A replay sends no webhook, notification, or event: rule actions produce no outward effects.",
4874
+ description: `Re-evaluate a past execution (traceId) against a candidate version and return the decision diff (decisionChanged, effect changes, fired rules) plus a determinism verdict. Synchronous. ${DRY_RUN_LIMIT_NOTE} A replay sends no webhook, notification, or event: rule actions produce no outward effects.`,
4834
4875
  inputSchema: z10.object({
4835
4876
  traceId: z10.string().describe("Trace ID of the past execution to replay"),
4836
4877
  candidateVersionId: z10.string().uuid().describe("Version to re-evaluate against")
@@ -4885,7 +4926,7 @@ function registerReplayTools(server, callApi) {
4885
4926
  {
4886
4927
  title: "Cancel Replay Job",
4887
4928
  annotations: DESTRUCTIVE,
4888
- description: "Cooperatively cancel a PENDING or RUNNING window replay job. Other states are rejected. VIEWER role cannot cancel.",
4929
+ description: "Cancel a PENDING or RUNNING window replay job. Other states are rejected. VIEWER role cannot cancel.",
4889
4930
  inputSchema: z10.object({
4890
4931
  jobId: z10.string().describe("Replay job ID")
4891
4932
  })
@@ -4897,7 +4938,7 @@ function registerReplayTools(server, callApi) {
4897
4938
  {
4898
4939
  title: "Export Replay Result",
4899
4940
  annotations: READ_ONLY,
4900
- description: `Export a COMPLETED window replay job. A running job is rejected \u2014 a partial result reads as the whole thing on the receiving end. The two formats carry different things: CSV holds the effect blast radius, whose columns are fixed; JSON holds the changed samples and action parameters, which nest and whose keys differ per tenant. JSON is not a superset of CSV. Returns the file contents as text. ${EXPORT_LIMIT_NOTE}`,
4941
+ description: `Export a COMPLETED window replay job; a job that has not completed is rejected. The two formats carry different things: CSV holds the effect blast radius, whose columns are fixed; JSON holds the changed samples and action parameters, which nest and whose keys differ per tenant. JSON is not a superset of CSV. Returns the file contents as text. ${EXPORT_LIMIT_NOTE}`,
4901
4942
  inputSchema: z10.object({
4902
4943
  jobId: z10.string().describe("Replay job ID"),
4903
4944
  format: z10.enum(EXPORT_FORMATS).default("json").describe("Export format")
@@ -5428,9 +5469,14 @@ function createCli() {
5428
5469
  const program = new Command();
5429
5470
  program.name("lexq").description(
5430
5471
  "Command-line interface for the LexQ Decision Operations Platform. Manage policies, run Impact Simulation, and deploy from your terminal."
5431
- ).version(getVersion2(), "-V, --version").option("--format <format>", "Output format: json or table", "json").option("--api-key <key>", "Override stored API key").option(
5472
+ ).version(getVersion2(), "-V, --version").option(
5473
+ "--format <format>",
5474
+ `How results are printed: ${OUTPUT_FORMATS.join(" or ")}`,
5475
+ parseOutputFormat,
5476
+ "json"
5477
+ ).option("--api-key <key>", "Override stored API key").option(
5432
5478
  "--base-url <url>",
5433
- "Override API base URL \u2014 must include the API prefix, e.g. http://localhost:8080/api/v1/partners"
5479
+ "Override API base URL \u2014 must include the API prefix, e.g. https://api.lexq.io/api/v1/partners"
5434
5480
  ).option("--dry-run", "Preview the HTTP request without executing").option("--verbose", "Show request/response details").option("--no-color", "Disable colored output");
5435
5481
  registerAuthCommands(program);
5436
5482
  registerStatusCommand(program);
@@ -20,8 +20,8 @@ interface McpToolResult {
20
20
  * Function signature for making API calls.
21
21
  * Injected into tool registration functions for testability and reuse.
22
22
  *
23
- * - lexq-cli (stdio): uses createCallApiFromConfig() — reads ~/.lexq/config.json
24
- * - lexq-mcp (HTTP): uses createApiCaller(apiKey) — Bearer token from request
23
+ * - lexq serve --mcp (stdio): createCallApiFromConfig() reads ~/.lexq/config.json.
24
+ * - A host serving these tools over HTTP passes its own CallApi.
25
25
  */
26
26
  type CallApi = (method: string, path: string, opts?: {
27
27
  body?: unknown;
@@ -37,8 +37,8 @@ declare function paginationParams(page?: number, size?: number): Record<string,
37
37
  * Builds a salient, actionable warning from a response's meta when it reports facts that
38
38
  * rules reference but the schema does not define. Returns null when there is nothing to warn.
39
39
  *
40
- * Shared by both CallApi implementations (CLI stdio here, lexq-mcp HTTP via @lexq/cli/mcp)
41
- * so the agent-facing text is identical on every surface. The backend only populates
40
+ * Shared by every CallApi (the stdio server here, and any host through @lexq/cli/mcp) so the
41
+ * agent-facing text is identical on every surface. The API only populates
42
42
  * meta.unregisteredFacts on rule create/update, so this only ever fires there.
43
43
  */
44
44
  declare function formatUnregisteredFactWarning(meta: unknown): string | null;
@@ -84,7 +84,8 @@ declare const isLosslessNumber: (value: unknown) => value is LosslessNumber;
84
84
  * Registers all MCP tools on the given server.
85
85
  *
86
86
  * @param server - McpServer instance
87
- * @param callApi - API caller function (config-based for CLI, Bearer-based for HTTP)
87
+ * @param callApi - how tool calls reach the LexQ API. The stdio server builds one from
88
+ * ~/.lexq/config.json; a host passes its own.
88
89
  */
89
90
  declare function registerAllTools(server: McpServer, callApi: CallApi): void;
90
91
  /**
@@ -219,6 +219,7 @@ var STATS_DEFAULT_WINDOW_DAYS = 30;
219
219
  var STATS_MAX_WINDOW_DAYS = 31;
220
220
  var FAILURE_LOG_LOOKBACK_DAYS = 7;
221
221
  var PURGE_AFTER_DAYS = 30;
222
+ var MAX_DRY_RUN_EVALUATIONS_PER_MINUTE = 60;
222
223
 
223
224
  // src/mcp/tools/groups.ts
224
225
  function registerGroupTools(server, callApi) {
@@ -652,6 +653,7 @@ var FACT_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
652
653
 
653
654
  // src/lib/export.ts
654
655
  import { writeFileSync as writeFileSync2 } from "fs";
656
+ import { extname } from "path";
655
657
  var EXPORT_FORMATS = ExportFormat.map((name) => name.toLowerCase()).sort();
656
658
 
657
659
  // src/mcp/tools/facts.ts
@@ -863,7 +865,7 @@ function registerDeployTools(server, callApi) {
863
865
  {
864
866
  title: "Schedule Deployment",
865
867
  annotations: DESTRUCTIVE,
866
- description: "Schedule an ACTIVE version with a future effective start date to auto-deploy at that time (Scheduled Deployment). The time is the version's effectiveFrom, set on the DRAFT before publishing; this call takes no time of its own. One pending schedule per group; manual deploy/rollback/undeploy, starting an A/B test, or archiving the group cancels it. The snapshot hash is sealed at scheduling and re-verified at execution (fail-closed).",
868
+ description: "Schedule an ACTIVE version with a future effective start date to auto-deploy at that time (Scheduled Deployment). The time is the version's effectiveFrom, set on the DRAFT before publishing; this call takes no time of its own. One pending schedule per group; manual deploy/rollback/undeploy, starting an A/B test, or archiving the group cancels it. A scheduled deployment uses exactly the rules that were scheduled; if they do not match when it runs, it fails with HASH_MISMATCH and nothing is deployed.",
867
869
  inputSchema: z6.object({
868
870
  groupId: z6.string().uuid().describe("Policy group ID"),
869
871
  versionId: z6.string().uuid().describe("ACTIVE version ID with a future effective start date"),
@@ -977,6 +979,11 @@ function registerDeployTools(server, callApi) {
977
979
  // src/mcp/tools/analytics.ts
978
980
  import { z as z7 } from "zod";
979
981
  import dedent3 from "dedent";
982
+
983
+ // src/lib/dry-run-limit.ts
984
+ var DRY_RUN_LIMIT_NOTE = `Not billed and not counted toward the plan's usage. Dry runs, dry run compares, and single-decision replays share a limit of ${MAX_DRY_RUN_EVALUATIONS_PER_MINUTE} evaluations per minute for each organization; a compare counts as two. Past the limit, a call fails with 429 (C-007); wait a minute before trying again.`;
985
+
986
+ // src/mcp/tools/analytics.ts
980
987
  function datasetBlock(path) {
981
988
  const dataset = { type: "UPLOADED", source: "S3_BUCKET", path };
982
989
  return {
@@ -991,7 +998,7 @@ function registerAnalyticsTools(server, callApi) {
991
998
  title: "Dry Run",
992
999
  annotations: READ_ONLY,
993
1000
  description: dedent3`
994
- Execute a single dry run against a version. Tests how rules evaluate given input facts without side effects.
1001
+ Execute a single dry run against a version. Tests how rules evaluate given input facts without side effects. ${DRY_RUN_LIMIT_NOTE}
995
1002
 
996
1003
  Returns:
997
1004
  inputFacts — normalized input facts
@@ -1020,7 +1027,7 @@ function registerAnalyticsTools(server, callApi) {
1020
1027
  title: "Dry Run Compare",
1021
1028
  annotations: READ_ONLY,
1022
1029
  description: dedent3`
1023
- Compare dry run results between two versions using the same input facts. Useful for validating changes.
1030
+ Compare dry run results between two versions using the same input facts. Useful for validating changes. ${DRY_RUN_LIMIT_NOTE}
1024
1031
 
1025
1032
  Returns:
1026
1033
  resultA / resultB — full DryRunResponse for each version
@@ -1060,8 +1067,9 @@ function registerAnalyticsTools(server, callApi) {
1060
1067
  description: dedent3`
1061
1068
  Start an Impact Simulation against historical, uploaded, or inline data.
1062
1069
 
1063
- Starting checks the plan's simulation usage limit. The records a simulation processes
1064
- count toward that usage when it completes or is canceled.
1070
+ Every record a simulation processes counts toward the plan's simulation usage, including
1071
+ records processed before a cancel. When the plan's limit is reached, a new simulation
1072
+ cannot start.
1065
1073
 
1066
1074
  dataset.type and dataset.source are BOTH required, and must be paired:
1067
1075
  HISTORICAL → source EXECUTION_LOGS, with dataset.from / dataset.to (yyyy-MM-dd, whole days
@@ -1170,7 +1178,7 @@ function registerAnalyticsTools(server, callApi) {
1170
1178
  annotations: ADDITIVE,
1171
1179
  description: dedent3`
1172
1180
  Upload inline CSV or JSON content as a simulation dataset.
1173
- The content is uploaded to S3 and a path is returned in the "path" field.
1181
+ The content is stored as a dataset, and its path is returned in the "path" field.
1174
1182
 
1175
1183
  To use the returned path in lexq_simulation_start, set:
1176
1184
  dataset: { "type": "UPLOADED", "source": "S3_BUCKET", "path": "<returned path>" }
@@ -1250,7 +1258,7 @@ function registerProfileTools(server, callApi) {
1250
1258
  {
1251
1259
  title: "Group Latency Profile",
1252
1260
  annotations: READ_ONLY,
1253
- description: "Per-rule latency profile of a policy group over a time window: group TOTAL distribution split by cache state (HIT = compiled ruleset cache hit, MISS = deep-load + compile), a per-rule CONDITION/ACTION percentile table, and slow-rule flags. " + RELATIVE_THRESHOLD + ` Every percentile is accompanied by its sample count n; a percentile is withheld (null) unless n\xD7(1\u2212q) \u2265 ${TAIL_MIN_OBS} (p50 needs n \u2265 ${PERCENTILE_MIN_N.p50}, p95 n \u2265 ${PERCENTILE_MIN_N.p95}, p99 n \u2265 ${PERCENTILE_MIN_N.p99} \u2014 display gate, separate from the n \u2265 ${MIN_SAMPLES} judgment gate). Baselines report INSUFFICIENT_COHORT when fewer than ${MIN_COHORT_SIZE} rules qualify. Rule detail comes from a deterministic ${PROFILE_SAMPLE_PERMILLE / 10}% sample of calls; TOTAL is recorded for every call. Defaults: last ${PROFILE_DEFAULT_WINDOW_HOURS}h, live version, cacheState HIT.`,
1261
+ description: "Per-rule latency profile of a policy group over a time window: group TOTAL distribution split by cacheState (HIT = calls on a version already prepared for execution, MISS = calls that had to prepare the version first and run slower), a per-rule CONDITION/ACTION percentile table, and slow-rule flags. " + RELATIVE_THRESHOLD + ` Every percentile is accompanied by its sample count n; a percentile is withheld (null) unless n\xD7(1\u2212q) \u2265 ${TAIL_MIN_OBS} (p50 needs n \u2265 ${PERCENTILE_MIN_N.p50}, p95 n \u2265 ${PERCENTILE_MIN_N.p95}, p99 n \u2265 ${PERCENTILE_MIN_N.p99} \u2014 display gate, separate from the n \u2265 ${MIN_SAMPLES} judgment gate). Baselines report INSUFFICIENT_COHORT when fewer than ${MIN_COHORT_SIZE} rules qualify. Rule detail comes from a ${PROFILE_SAMPLE_PERMILLE / 10}% sample of calls; TOTAL is recorded for every call. Defaults: last ${PROFILE_DEFAULT_WINDOW_HOURS}h, live version, cacheState HIT.`,
1254
1262
  inputSchema: z8.object({
1255
1263
  groupId: z8.string().uuid().describe("Policy group ID"),
1256
1264
  versionId: z8.string().uuid().optional().describe("Version to inspect (default: live version)"),
@@ -1295,7 +1303,7 @@ function registerReplayTools(server, callApi) {
1295
1303
  {
1296
1304
  title: "Replay a Decision",
1297
1305
  annotations: READ_ONLY,
1298
- description: "Re-evaluate a past execution (traceId) against a candidate version and return the decision diff (decisionChanged, effect changes, fired rules) plus a determinism verdict. Synchronous and free of charge (TPS throttle only). A replay sends no webhook, notification, or event: rule actions produce no outward effects.",
1306
+ description: `Re-evaluate a past execution (traceId) against a candidate version and return the decision diff (decisionChanged, effect changes, fired rules) plus a determinism verdict. Synchronous. ${DRY_RUN_LIMIT_NOTE} A replay sends no webhook, notification, or event: rule actions produce no outward effects.`,
1299
1307
  inputSchema: z9.object({
1300
1308
  traceId: z9.string().describe("Trace ID of the past execution to replay"),
1301
1309
  candidateVersionId: z9.string().uuid().describe("Version to re-evaluate against")
@@ -1350,7 +1358,7 @@ function registerReplayTools(server, callApi) {
1350
1358
  {
1351
1359
  title: "Cancel Replay Job",
1352
1360
  annotations: DESTRUCTIVE,
1353
- description: "Cooperatively cancel a PENDING or RUNNING window replay job. Other states are rejected. VIEWER role cannot cancel.",
1361
+ description: "Cancel a PENDING or RUNNING window replay job. Other states are rejected. VIEWER role cannot cancel.",
1354
1362
  inputSchema: z9.object({
1355
1363
  jobId: z9.string().describe("Replay job ID")
1356
1364
  })
@@ -1362,7 +1370,7 @@ function registerReplayTools(server, callApi) {
1362
1370
  {
1363
1371
  title: "Export Replay Result",
1364
1372
  annotations: READ_ONLY,
1365
- description: `Export a COMPLETED window replay job. A running job is rejected \u2014 a partial result reads as the whole thing on the receiving end. The two formats carry different things: CSV holds the effect blast radius, whose columns are fixed; JSON holds the changed samples and action parameters, which nest and whose keys differ per tenant. JSON is not a superset of CSV. Returns the file contents as text. ${EXPORT_LIMIT_NOTE}`,
1373
+ description: `Export a COMPLETED window replay job; a job that has not completed is rejected. The two formats carry different things: CSV holds the effect blast radius, whose columns are fixed; JSON holds the changed samples and action parameters, which nest and whose keys differ per tenant. JSON is not a superset of CSV. Returns the file contents as text. ${EXPORT_LIMIT_NOTE}`,
1366
1374
  inputSchema: z9.object({
1367
1375
  jobId: z9.string().describe("Replay job ID"),
1368
1376
  format: z9.enum(EXPORT_FORMATS).default("json").describe("Export format")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexq/cli",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "mcpName": "io.lexq/lexq",
5
5
  "description": "The Decision Operations Platform for engineering teams",
6
6
  "type": "module",
@@ -47,6 +47,7 @@
47
47
  "test:rollback": "pnpm build && node tests/rollback.mjs",
48
48
  "test:dataset-upload": "pnpm build && node tests/dataset-upload.mjs",
49
49
  "test:export-limit": "pnpm build && node tests/export-limit.mjs",
50
+ "test:format-options": "pnpm build && node tests/format-options.mjs",
50
51
  "prepublishOnly": "pnpm build",
51
52
  "knip": "knip",
52
53
  "format": "prettier --write \"src/**/*.ts\"",
@@ -81,8 +81,7 @@ Use stats to monitor:
81
81
 
82
82
  ## 2. Failure Logs
83
83
 
84
- Failure logs are a dead-letter record for background tasks that failed after the request returned.
85
- Two task types exist:
84
+ Failure logs record background tasks that failed. Two task types exist:
86
85
 
87
86
  | Task type | What failed |
88
87
  |------------------------|------------------------------------------------------------------|
@@ -171,12 +170,12 @@ lexq deploy schedules --format table
171
170
  lexq deploy unschedule --group-id <gid> --force
172
171
  ```
173
172
 
174
- One pending schedule per group. The scheduler deploys within one tick (≤60s) of the effective
175
- start. A manual deploy, rollback, undeploy, A/B test start, or group archive **cancels** the
176
- pending schedule — the cancellation reason is recorded in the schedule ledger.
173
+ One pending schedule per group. A scheduled deployment goes live within 60 seconds after its
174
+ effective start. A manual deploy, rollback, undeploy, A/B test start, or group archive **cancels**
175
+ the pending schedule, and the schedule's `canceledReason` says which one.
177
176
 
178
- The snapshot hash is sealed at scheduling time. If the version changes before the effective start,
179
- the scheduled deploy fails with `HASH_MISMATCH` rather than deploying something unexpected.
177
+ A scheduled deployment uses exactly the rules that were scheduled. If they do not match when it
178
+ runs, it fails with `HASH_MISMATCH` and nothing is deployed.
180
179
 
181
180
  ## 4. Decision Provenance
182
181
 
@@ -226,9 +225,8 @@ lexq replay export --id <jobId> --as csv --output blast-radius.csv
226
225
  Replay reports whether the outcome is `DETERMINISTIC` or `REPLAY_MAY_DIFFER` — the latter when the
227
226
  original execution depended on values the replay cannot reproduce.
228
227
 
229
- Only a `COMPLETED` job can be exported; a running one has no full result yet, and a partial one reads
230
- as the whole thing on the receiving end. The two formats answer different questions — CSV carries the
231
- effect blast radius, whose columns are fixed, and JSON carries the changed samples and action
228
+ Only a `COMPLETED` job can be exported. The two formats answer different questions — CSV carries
229
+ the effect blast radius, whose columns are fixed, and JSON carries the changed samples and action
232
230
  parameters, which nest and whose keys differ per tenant.
233
231
 
234
232
  ## 6. Latency Profile
@@ -249,8 +247,9 @@ lexq profile <groupId> --version-id <vid> --cache MISS \
249
247
  `--from` / `--to` are ISO-8601 **instants** here (`2026-07-01T00:00:00Z`), unlike `replay start`
250
248
  which takes `yyyy-MM-dd` days in the organization time zone.
251
249
 
252
- `p50` / `p95` / `p99` are `null` when the sample is under 100 — the engine reports insufficient
253
- data rather than inventing a number. `n` is always present.
250
+ `p50` / `p95` / `p99` are `null` until the sample is large enough to show them, and the engine
251
+ never estimates a withheld one. `lexq profile --help` lists the thresholds, and `n` is always
252
+ present. A rule whose sample is under 100 is never flagged.
254
253
 
255
254
  ## Agent Monitoring Workflow
256
255
 
@@ -152,12 +152,7 @@ tables of `lexq deploy history`, `deploy schedules`, `history list`, and
152
152
  **Constraint:** All groups sharing the same `activationGroup` **must** use identical
153
153
  `activationMode` and `activationStrategy`. The API rejects mismatches with a `P-` error.
154
154
 
155
- Groups are selected by the order you set, not by comparing action values. `FIRST_MATCH` and
156
- `MAX_BENEFIT` were removed on 2026-08-01 — the former resolved identically to
157
- `HIGHEST_PRIORITY` (candidates are evaluated in priority order, so "first match" *is* "lowest
158
- priority number"), and the latter compared post-action values as scores, which made a 20%
159
- discount lose to a 10% discount. The engine cannot know which direction is "better" for your
160
- domain.
155
+ Groups are selected by the priority order you set, not by comparing action values.
161
156
 
162
157
  ### Example: Exclusive Discount Groups
163
158
 
@@ -187,9 +182,8 @@ Split traffic between the current live version and a test version.
187
182
 
188
183
  ### Traffic Identity Key
189
184
 
190
- **Every execution request has to carry `context.trafficKey`.** That value is what the split is
191
- computed from: the server hashes it together with the group ID and assigns the request to a
192
- bucket, so the same key value always reaches the same version for that group.
185
+ **Every execution request has to carry `context.trafficKey`.** That value decides the split: the
186
+ same key value always reaches the same version for that group.
193
187
 
194
188
  Send it in `context`, not in `facts`. Routing never reads `facts`.
195
189
 
@@ -202,11 +196,10 @@ Send it in `context`, not in `facts`. Routing never reads `facts`.
202
196
 
203
197
  If the key is missing or empty, no request is ever assigned to the test version. The test still
204
198
  reports as running and execution logs keep accumulating, so **the failure does not surface in the
205
- response** — only a server-side warning records it. This is the most common reason a test sits
206
- at 0%.
199
+ response**. This is the most common reason a test sits at 0%.
207
200
 
208
201
  Pick a value that is stable per experiment unit (a customer ID, a merchant ID, a session ID) and
209
- opaque — the whole `context` object is stored on the execution ledger, so send a hash or a
202
+ opaque — the whole `context` object is stored in execution history, so send a hash or a
210
203
  surrogate key rather than an email address. A value that changes every request, such as a request
211
204
  ID, scatters one user across both versions and destroys the comparison.
212
205
 
@@ -376,11 +376,10 @@ live but from a newer deployment. Start again from step 1.
376
376
 
377
377
  **Goal:** Check for pending failures and find their cause.
378
378
 
379
- Failure logs are a dead-letter record for background tasks — platform event webhooks and scheduled
380
- deployments. There is no retry action: the engine does not re-execute a failed task on request.
381
- Fix the cause at the source (webhook endpoint, version state). The CLI can read failure logs but
382
- cannot resolve or ignore them: an organization admin does that in the console, under
383
- Management → Failure Logs (https://console.lexq.io/management/failure-logs).
379
+ Failure logs record platform event webhooks and scheduled deployments that failed. There is no
380
+ retry action; fix the cause at the source (webhook endpoint, version state). The CLI can read
381
+ failure logs but cannot resolve or ignore them: an organization admin does that in the console,
382
+ under Management → Failure Logs (https://console.lexq.io/management/failure-logs).
384
383
 
385
384
  ```bash
386
385
  # 1. List pending failures
@@ -72,16 +72,19 @@ Every command accepts these flags:
72
72
 
73
73
  | Flag | Description | Default |
74
74
  |--------------------------|--------------------------------------------|---------------------------------------|
75
- | `--format <json\|table>` | Output format | `json` |
75
+ | `--format <json\|table>` | How results are printed | `json` |
76
76
  | `--api-key <key>` | Override stored API key | from config |
77
77
  | `--base-url <url>` | Override API base URL | `https://api.lexq.io/api/v1/partners` |
78
78
  | `--dry-run` | Preview the HTTP request without executing | off |
79
79
  | `--verbose` | Show request/response details | off |
80
80
  | `--no-color` | Disable colored output | off |
81
81
 
82
- **Agent best practice:** Always use `--format json` (the default). Parse JSON output programmatically. Use
82
+ **Agent best practice:** Leave `--format` at its default, `json`, and parse the output programmatically. Use
83
83
  `--format table` only when displaying to humans.
84
84
 
85
+ `--format` does not change a file. The commands that write one (`facts export`, `analytics simulation export`,
86
+ `replay export`, `analytics dataset template`) choose its format with `--as csv` or `--as json`.
87
+
85
88
  ## Command Groups
86
89
 
87
90
  | Group | Commands | Description |
@@ -105,7 +108,7 @@ Every command accepts these flags:
105
108
 
106
109
  ## Pagination
107
110
 
108
- All list endpoints return a `PageResponse`:
111
+ All list endpoints return one page of results:
109
112
 
110
113
  ```json
111
114
  {
@@ -197,7 +197,7 @@ Each code says **who fixes it**.
197
197
 
198
198
  `CONDITION_VALUE_TYPE_INVALID` is worth escalating. The right-hand side of a condition belongs
199
199
  to the rule, not to your request, so changing the payload will not help — the rule fails the
200
- same way for every caller. It also got past the check that is supposed to reject it on save.
200
+ same way for every caller.
201
201
 
202
202
  A rule from another group referencing facts you did not send yields `FACT_NOT_PROVIDED` — this
203
203
  is normal, not an error.
@@ -263,7 +263,7 @@ lexq analytics dataset template \
263
263
 
264
264
  # 2. Upload dataset
265
265
  lexq analytics dataset upload --file ./my-data.csv
266
- # → path: datasets/<tenantId>/a1b2c3d4e5f6.csv
266
+ # → returns a path. Pass it to simulation start unchanged.
267
267
 
268
268
  # 3. Start simulation with uploaded path
269
269
  lexq analytics simulation start --json '{