@lexq/cli 3.0.1 → 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
@@ -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/dist/index.js CHANGED
@@ -793,7 +793,8 @@ function registerVersionCommands(program) {
793
793
 
794
794
  A version is an immutable snapshot of rules within a policy group.
795
795
 
796
- 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.
797
798
 
798
799
  Commands:
799
800
  list List versions for a group
@@ -1539,7 +1540,7 @@ ${data.length} unregistered`);
1539
1540
  STRING types take --allowed-values. NUMBER types take all three.
1540
1541
  BOOLEAN takes none — it has only two values to begin with.
1541
1542
  For list types the value domain applies to each element.
1542
- Leave them out for no value domain, which is how facts behaved before.
1543
+ Leave them out for no value domain.
1543
1544
  `
1544
1545
  ).action(async (opts) => {
1545
1546
  try {
@@ -2101,8 +2102,8 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2101
2102
  "after",
2102
2103
  dedent7`
2103
2104
 
2104
- The version must be ACTIVE with a future effective start date; the system
2105
- 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.
2106
2107
  That time is the version's effectiveFrom, set on the DRAFT before publishing;
2107
2108
  this command takes no time of its own.
2108
2109
  One pending schedule per group. Manual deploy/rollback/undeploy, starting
@@ -2256,7 +2257,7 @@ function uploadDataset(bytes, filename, options) {
2256
2257
  }
2257
2258
 
2258
2259
  // src/lib/dry-run-limit.ts
2259
- 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) until the next minute starts.`;
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.`;
2260
2261
 
2261
2262
  // src/commands/analytics.ts
2262
2263
  function registerAnalyticsCommands(program) {
@@ -2785,7 +2786,7 @@ function registerProfileCommands(program) {
2785
2786
  per-rule p50s within the group. Absolute ms thresholds are intentionally
2786
2787
  not supported. Each percentile is withheld (–) unless n×(1−q) ≥ ${TAIL_MIN_OBS} —
2787
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
2788
- 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.
2789
2790
 
2790
2791
  Examples:
2791
2792
  $ lexq profile <groupId>
@@ -2843,7 +2844,9 @@ function registerProfileCommands(program) {
2843
2844
  console.log(`baseline ${b.phase.padEnd(9)}: ${base}`);
2844
2845
  }
2845
2846
  if (data.droppedRows > 0) {
2846
- 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
+ );
2847
2850
  }
2848
2851
  printTable(
2849
2852
  ["Rule", "Phase", "n", "p50(ms)", "p95(ms)", "p99(ms)", "\xD7base", "Flagged"],
@@ -3167,8 +3170,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
3167
3170
  "after",
3168
3171
  dedent11`
3169
3172
 
3170
- Only COMPLETED jobs can be exported. A running job has no full result yet, and a
3171
- partial one reads as the whole thing on the receiving end.
3173
+ Only COMPLETED jobs can be exported.
3172
3174
 
3173
3175
  The two formats carry different things. CSV holds the effect blast radius, whose
3174
3176
  columns are fixed. JSON holds the changed samples and action parameters, which
@@ -3302,7 +3304,7 @@ function registerLogCommands(program) {
3302
3304
  "after",
3303
3305
  dedent13`
3304
3306
 
3305
- 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.
3306
3308
 
3307
3309
  Commands:
3308
3310
  list List failure logs with filters
@@ -3791,8 +3793,9 @@ function registerAnalyticsTools(server, callApi) {
3791
3793
  description: dedent15`
3792
3794
  Start an Impact Simulation against historical, uploaded, or inline data.
3793
3795
 
3794
- Starting checks the plan's simulation usage limit. The records a simulation processes
3795
- 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.
3796
3799
 
3797
3800
  dataset.type and dataset.source are BOTH required, and must be paired:
3798
3801
  HISTORICAL → source EXECUTION_LOGS, with dataset.from / dataset.to (yyyy-MM-dd, whole days
@@ -3901,7 +3904,7 @@ function registerAnalyticsTools(server, callApi) {
3901
3904
  annotations: ADDITIVE,
3902
3905
  description: dedent15`
3903
3906
  Upload inline CSV or JSON content as a simulation dataset.
3904
- 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.
3905
3908
 
3906
3909
  To use the returned path in lexq_simulation_start, set:
3907
3910
  dataset: { "type": "UPLOADED", "source": "S3_BUCKET", "path": "<returned path>" }
@@ -4695,7 +4698,7 @@ function registerDeployTools(server, callApi) {
4695
4698
  {
4696
4699
  title: "Schedule Deployment",
4697
4700
  annotations: DESTRUCTIVE,
4698
- 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.",
4699
4702
  inputSchema: z8.object({
4700
4703
  groupId: z8.string().uuid().describe("Policy group ID"),
4701
4704
  versionId: z8.string().uuid().describe("ACTIVE version ID with a future effective start date"),
@@ -4823,7 +4826,7 @@ function registerProfileTools(server, callApi) {
4823
4826
  {
4824
4827
  title: "Group Latency Profile",
4825
4828
  annotations: READ_ONLY,
4826
- 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.`,
4827
4830
  inputSchema: z9.object({
4828
4831
  groupId: z9.string().uuid().describe("Policy group ID"),
4829
4832
  versionId: z9.string().uuid().optional().describe("Version to inspect (default: live version)"),
@@ -4923,7 +4926,7 @@ function registerReplayTools(server, callApi) {
4923
4926
  {
4924
4927
  title: "Cancel Replay Job",
4925
4928
  annotations: DESTRUCTIVE,
4926
- 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.",
4927
4930
  inputSchema: z10.object({
4928
4931
  jobId: z10.string().describe("Replay job ID")
4929
4932
  })
@@ -4935,7 +4938,7 @@ function registerReplayTools(server, callApi) {
4935
4938
  {
4936
4939
  title: "Export Replay Result",
4937
4940
  annotations: READ_ONLY,
4938
- 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}`,
4939
4942
  inputSchema: z10.object({
4940
4943
  jobId: z10.string().describe("Replay job ID"),
4941
4944
  format: z10.enum(EXPORT_FORMATS).default("json").describe("Export format")
@@ -5473,7 +5476,7 @@ function createCli() {
5473
5476
  "json"
5474
5477
  ).option("--api-key <key>", "Override stored API key").option(
5475
5478
  "--base-url <url>",
5476
- "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"
5477
5480
  ).option("--dry-run", "Preview the HTTP request without executing").option("--verbose", "Show request/response details").option("--no-color", "Disable colored output");
5478
5481
  registerAuthCommands(program);
5479
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
  /**
@@ -865,7 +865,7 @@ function registerDeployTools(server, callApi) {
865
865
  {
866
866
  title: "Schedule Deployment",
867
867
  annotations: DESTRUCTIVE,
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. 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.",
869
869
  inputSchema: z6.object({
870
870
  groupId: z6.string().uuid().describe("Policy group ID"),
871
871
  versionId: z6.string().uuid().describe("ACTIVE version ID with a future effective start date"),
@@ -981,7 +981,7 @@ import { z as z7 } from "zod";
981
981
  import dedent3 from "dedent";
982
982
 
983
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) until the next minute starts.`;
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
985
 
986
986
  // src/mcp/tools/analytics.ts
987
987
  function datasetBlock(path) {
@@ -1067,8 +1067,9 @@ function registerAnalyticsTools(server, callApi) {
1067
1067
  description: dedent3`
1068
1068
  Start an Impact Simulation against historical, uploaded, or inline data.
1069
1069
 
1070
- Starting checks the plan's simulation usage limit. The records a simulation processes
1071
- 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.
1072
1073
 
1073
1074
  dataset.type and dataset.source are BOTH required, and must be paired:
1074
1075
  HISTORICAL → source EXECUTION_LOGS, with dataset.from / dataset.to (yyyy-MM-dd, whole days
@@ -1177,7 +1178,7 @@ function registerAnalyticsTools(server, callApi) {
1177
1178
  annotations: ADDITIVE,
1178
1179
  description: dedent3`
1179
1180
  Upload inline CSV or JSON content as a simulation dataset.
1180
- 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.
1181
1182
 
1182
1183
  To use the returned path in lexq_simulation_start, set:
1183
1184
  dataset: { "type": "UPLOADED", "source": "S3_BUCKET", "path": "<returned path>" }
@@ -1257,7 +1258,7 @@ function registerProfileTools(server, callApi) {
1257
1258
  {
1258
1259
  title: "Group Latency Profile",
1259
1260
  annotations: READ_ONLY,
1260
- 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.`,
1261
1262
  inputSchema: z8.object({
1262
1263
  groupId: z8.string().uuid().describe("Policy group ID"),
1263
1264
  versionId: z8.string().uuid().optional().describe("Version to inspect (default: live version)"),
@@ -1357,7 +1358,7 @@ function registerReplayTools(server, callApi) {
1357
1358
  {
1358
1359
  title: "Cancel Replay Job",
1359
1360
  annotations: DESTRUCTIVE,
1360
- 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.",
1361
1362
  inputSchema: z9.object({
1362
1363
  jobId: z9.string().describe("Replay job ID")
1363
1364
  })
@@ -1369,7 +1370,7 @@ function registerReplayTools(server, callApi) {
1369
1370
  {
1370
1371
  title: "Export Replay Result",
1371
1372
  annotations: READ_ONLY,
1372
- 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}`,
1373
1374
  inputSchema: z9.object({
1374
1375
  jobId: z9.string().describe("Replay job ID"),
1375
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.1",
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",
@@ -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
@@ -108,7 +108,7 @@ Every command accepts these flags:
108
108
 
109
109
  ## Pagination
110
110
 
111
- All list endpoints return a `PageResponse`:
111
+ All list endpoints return one page of results:
112
112
 
113
113
  ```json
114
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 '{