@lexq/cli 2.1.1 → 3.0.1

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/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";
@@ -425,7 +434,6 @@ var SimulationDatasetSource = ["EXECUTION_LOGS", "S3_BUCKET", "REQUEST_BODY"];
425
434
  var SimulationMetricType = ["COUNT", "SUM", "AVG"];
426
435
  var ExportFormat = ["CSV", "JSON"];
427
436
  var FailureStatus = ["PENDING", "RESOLVED", "IGNORED"];
428
- var FailureAction = ["IGNORE", "RESOLVE"];
429
437
  var TaskType = ["PLATFORM_WEBHOOK", "SCHEDULED_DEPLOYMENT"];
430
438
  var PlatformEventType = [
431
439
  "VERSION_PUBLISHED",
@@ -1347,6 +1355,7 @@ function escapeUnsafe(json) {
1347
1355
 
1348
1356
  // src/lib/export.ts
1349
1357
  import { writeFileSync as writeFileSync2 } from "fs";
1358
+ import { extname } from "path";
1350
1359
  var EXPORT_FORMATS = ExportFormat.map((name) => name.toLowerCase()).sort();
1351
1360
  function parseFormat(value) {
1352
1361
  if (typeof value === "string" && EXPORT_FORMATS.includes(value)) {
@@ -1356,6 +1365,25 @@ function parseFormat(value) {
1356
1365
  `Unsupported export format: ${String(value)}. Use ${EXPORT_FORMATS.join(" or ")}.`
1357
1366
  );
1358
1367
  }
1368
+ function fileFormat(command) {
1369
+ const opts = command.opts();
1370
+ const format = parseFormat(opts.as);
1371
+ const extension = typeof opts.output === "string" ? extname(opts.output).slice(1).toLowerCase() : "";
1372
+ let root = command;
1373
+ while (root.parent) root = root.parent;
1374
+ if (EXPORT_FORMATS.includes(extension)) {
1375
+ if (extension !== format) {
1376
+ console.error(
1377
+ `Note: ${opts.output} will hold ${format}. Pass --as ${extension} to write ${extension}.`
1378
+ );
1379
+ }
1380
+ } else if (root.getOptionValueSource("format") === "cli" && command.getOptionValueSource("as") !== "cli") {
1381
+ console.error(
1382
+ `Note: --format sets how results are printed, not the file. This file is ${format}; choose ${EXPORT_FORMATS.join(" or ")} with --as.`
1383
+ );
1384
+ }
1385
+ return format;
1386
+ }
1359
1387
  async function runExport(path, format, options) {
1360
1388
  const { output, params, ...clientOptions } = options;
1361
1389
  const body = await apiRequest("GET", path, {
@@ -1622,10 +1650,10 @@ ${warning}
1622
1650
  $ lexq facts export --as json --output facts.json
1623
1651
  $ lexq facts export --keyword user --as json
1624
1652
  `
1625
- ).action(async (opts) => {
1653
+ ).action(async (opts, command) => {
1626
1654
  try {
1627
1655
  const globalOpts = program.opts();
1628
- await runExport("schema/facts/export", parseFormat(opts.as), {
1656
+ await runExport("schema/facts/export", fileFormat(command), {
1629
1657
  apiKey: globalOpts.apiKey,
1630
1658
  baseUrl: globalOpts.baseUrl,
1631
1659
  dryRun: globalOpts.dryRun,
@@ -2209,13 +2237,13 @@ import "commander";
2209
2237
  import dedent8 from "dedent";
2210
2238
 
2211
2239
  // src/lib/dataset-upload.ts
2212
- import { extname } from "path";
2240
+ import { extname as extname2 } from "path";
2213
2241
  var DATASET_CONTENT_TYPES = {
2214
2242
  ".csv": "text/csv",
2215
2243
  ".json": "application/json"
2216
2244
  };
2217
2245
  function datasetContentType(filename) {
2218
- return DATASET_CONTENT_TYPES[extname(filename).toLowerCase()];
2246
+ return DATASET_CONTENT_TYPES[extname2(filename).toLowerCase()];
2219
2247
  }
2220
2248
  function uploadDataset(bytes, filename, options) {
2221
2249
  const form = new FormData();
@@ -2227,6 +2255,9 @@ function uploadDataset(bytes, filename, options) {
2227
2255
  });
2228
2256
  }
2229
2257
 
2258
+ // 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
+
2230
2261
  // src/commands/analytics.ts
2231
2262
  function registerAnalyticsCommands(program) {
2232
2263
  const analytics = program.command("analytics").description("Dry run, simulation, and requirements").addHelpText(
@@ -2256,6 +2287,8 @@ function registerAnalyticsCommands(program) {
2256
2287
  $ lexq analytics dry-run --version-id <vid> --file test-input.json
2257
2288
 
2258
2289
  The request body must include a "facts" object. Use --debug for execution traces
2290
+
2291
+ ${DRY_RUN_LIMIT_NOTE}
2259
2292
  `
2260
2293
  ).action(async (opts) => {
2261
2294
  try {
@@ -2294,6 +2327,8 @@ function registerAnalyticsCommands(program) {
2294
2327
  }'
2295
2328
 
2296
2329
  Shows side-by-side which rules matched and what actions fired for each version.
2330
+
2331
+ ${DRY_RUN_LIMIT_NOTE}
2297
2332
  `
2298
2333
  ).action(async (opts) => {
2299
2334
  try {
@@ -2605,20 +2640,16 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2605
2640
  $ lexq analytics simulation export --id <simId> --as csv --output results.csv
2606
2641
  $ lexq analytics simulation export --id <simId> --as json
2607
2642
  `
2608
- ).action(async (opts) => {
2643
+ ).action(async (opts, command) => {
2609
2644
  try {
2610
2645
  const globalOpts = program.opts();
2611
- await runExport(
2612
- `analytics/simulations/${opts.id}/export`,
2613
- parseFormat(opts.as),
2614
- {
2615
- apiKey: globalOpts.apiKey,
2616
- baseUrl: globalOpts.baseUrl,
2617
- dryRun: globalOpts.dryRun,
2618
- verbose: globalOpts.verbose,
2619
- output: opts.output
2620
- }
2621
- );
2646
+ await runExport(`analytics/simulations/${opts.id}/export`, fileFormat(command), {
2647
+ apiKey: globalOpts.apiKey,
2648
+ baseUrl: globalOpts.baseUrl,
2649
+ dryRun: globalOpts.dryRun,
2650
+ verbose: globalOpts.verbose,
2651
+ output: opts.output
2652
+ });
2622
2653
  } catch (error) {
2623
2654
  printError(error);
2624
2655
  process.exit(1);
@@ -2671,7 +2702,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2671
2702
  $ lexq analytics dataset template --group-id <gid> --version-id <vid> --output template.csv
2672
2703
  $ lexq analytics dataset template --group-id <gid> --version-id <vid> --as json
2673
2704
  `
2674
- ).action(async (opts) => {
2705
+ ).action(async (opts, command) => {
2675
2706
  try {
2676
2707
  const globalOpts = program.opts();
2677
2708
  const config = loadConfig();
@@ -2680,7 +2711,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2680
2711
  if (!apiKey) {
2681
2712
  throw new Error('Not authenticated. Run "lexq auth login" first.');
2682
2713
  }
2683
- const fmt = parseFormat(opts.as);
2714
+ const fmt = fileFormat(command);
2684
2715
  const url = new URL(
2685
2716
  `analytics/groups/${opts.groupId}/versions/${opts.versionId}/dataset-template`,
2686
2717
  baseUrl.endsWith("/") ? baseUrl : baseUrl + "/"
@@ -3012,8 +3043,10 @@ function registerReplayCommands(program) {
3012
3043
  "after",
3013
3044
  dedent11`
3014
3045
 
3015
- Free of charge (TPS throttle only). Returns decisionChanged, effect
3016
- changes, fired rules on both sides, and a determinism verdict.
3046
+ Returns decisionChanged, effect changes, fired rules on both sides,
3047
+ and a determinism verdict.
3048
+
3049
+ ${DRY_RUN_LIMIT_NOTE}
3017
3050
 
3018
3051
  Example:
3019
3052
  $ lexq replay decision --trace-id <tid> --version-id <vid>
@@ -3145,10 +3178,10 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
3145
3178
  $ lexq replay export --id <jobId> --as csv --output blast-radius.csv
3146
3179
  $ lexq replay export --id <jobId> --as json
3147
3180
  `
3148
- ).action(async (opts) => {
3181
+ ).action(async (opts, command) => {
3149
3182
  try {
3150
3183
  const globalOpts = program.opts();
3151
- await runExport(`replay/jobs/${opts.id}/export`, parseFormat(opts.as), {
3184
+ await runExport(`replay/jobs/${opts.id}/export`, fileFormat(command), {
3152
3185
  apiKey: globalOpts.apiKey,
3153
3186
  baseUrl: globalOpts.baseUrl,
3154
3187
  dryRun: globalOpts.dryRun,
@@ -3254,6 +3287,10 @@ function withNotes(notes) {
3254
3287
  return Object.entries(notes).map(([value, note]) => note ? `${value} (${note})` : value);
3255
3288
  }
3256
3289
 
3290
+ // src/lib/console.ts
3291
+ var CONSOLE_URL = "https://console.lexq.io";
3292
+ var FAILURE_LOG_CONSOLE_NOTE = `Logs can be read here but not resolved or ignored. An organization admin does that in the LexQ console: Management \u2192 Failure Logs (${CONSOLE_URL}/management/failure-logs).`;
3293
+
3257
3294
  // src/commands/logs.ts
3258
3295
  var STATUS_NOTES = {
3259
3296
  PENDING: "needs attention",
@@ -3270,8 +3307,8 @@ function registerLogCommands(program) {
3270
3307
  Commands:
3271
3308
  list List failure logs with filters
3272
3309
  get Get failure log detail (includes the original payload)
3273
- action Process a single log (${FailureAction.join(", ")})
3274
- bulk-action Process multiple logs at once
3310
+
3311
+ ${FAILURE_LOG_CONSOLE_NOTE}
3275
3312
 
3276
3313
  Statuses: ${withNotes(STATUS_NOTES).join(", ")}
3277
3314
  `
@@ -3332,7 +3369,8 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
3332
3369
  dedent13`
3333
3370
 
3334
3371
  Includes the full payload that was used for the failed operation.
3335
- Use this to inspect what went wrong before deciding to ${FailureAction.join(" or ")}.
3372
+ Use this to inspect what went wrong. Resolving or ignoring a log happens in the
3373
+ console; see lexq logs --help.
3336
3374
  `
3337
3375
  ).action(async (opts) => {
3338
3376
  try {
@@ -3349,63 +3387,6 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
3349
3387
  process.exit(1);
3350
3388
  }
3351
3389
  });
3352
- logs.command("action").description(`Process a failure log action (${FailureAction.join(", ")})`).requiredOption("--id <logId>", "Log ID").requiredOption("--action <action>", `Action: ${FailureAction.join(" or ")}`).addHelpText(
3353
- "after",
3354
- dedent13`
3355
-
3356
- Actions:
3357
- IGNORE Mark as intentionally skipped (won't appear in PENDING)
3358
- RESOLVE Mark as manually resolved (e.g., fixed via external system)
3359
-
3360
- Example:
3361
- $ lexq logs action --id <logId> --action RESOLVE
3362
- `
3363
- ).action(async (opts) => {
3364
- try {
3365
- const globalOpts = program.opts();
3366
- const data = await apiRequest(
3367
- "POST",
3368
- `failure-logs/${opts.id}/actions`,
3369
- {
3370
- apiKey: globalOpts.apiKey,
3371
- baseUrl: globalOpts.baseUrl,
3372
- dryRun: globalOpts.dryRun,
3373
- verbose: globalOpts.verbose,
3374
- params: { action: opts.action }
3375
- }
3376
- );
3377
- printJson(data);
3378
- } catch (error) {
3379
- printError(error);
3380
- process.exit(1);
3381
- }
3382
- });
3383
- logs.command("bulk-action").description("Bulk process failure logs").requiredOption("--ids <logIds>", "Comma-separated log IDs").requiredOption("--action <action>", `Action: ${FailureAction.join(" or ")}`).addHelpText(
3384
- "after",
3385
- dedent13`
3386
-
3387
- Processes each log individually. Failures are skipped with a warning.
3388
-
3389
- Example:
3390
- $ lexq logs bulk-action --ids "id1,id2,id3" --action RESOLVE
3391
- `
3392
- ).action(async (opts) => {
3393
- try {
3394
- const globalOpts = program.opts();
3395
- const logIds = opts.ids.split(",").map((id) => id.trim());
3396
- const data = await apiRequest("POST", "failure-logs/bulk-actions", {
3397
- apiKey: globalOpts.apiKey,
3398
- baseUrl: globalOpts.baseUrl,
3399
- dryRun: globalOpts.dryRun,
3400
- verbose: globalOpts.verbose,
3401
- body: { logIds, action: opts.action }
3402
- });
3403
- printJson(data);
3404
- } catch (error) {
3405
- printError(error);
3406
- process.exit(1);
3407
- }
3408
- });
3409
3390
  }
3410
3391
 
3411
3392
  // src/commands/webhook-subscriptions.ts
@@ -3627,6 +3608,21 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
3627
3608
  import { McpServer } from "@modelcontextprotocol/server";
3628
3609
 
3629
3610
  // src/mcp/tools/_shared.ts
3611
+ var READ_ONLY = {
3612
+ readOnlyHint: true,
3613
+ destructiveHint: false,
3614
+ openWorldHint: false
3615
+ };
3616
+ var ADDITIVE = {
3617
+ readOnlyHint: false,
3618
+ destructiveHint: false,
3619
+ openWorldHint: false
3620
+ };
3621
+ var DESTRUCTIVE = {
3622
+ readOnlyHint: false,
3623
+ destructiveHint: true,
3624
+ openWorldHint: false
3625
+ };
3630
3626
  function localApiOptions() {
3631
3627
  const stored = loadConfig();
3632
3628
  return {
@@ -3649,9 +3645,8 @@ function createCallApiFromConfig() {
3649
3645
  form,
3650
3646
  params: opts?.params
3651
3647
  });
3652
- const content = [
3653
- { type: "text", text: stringifyJson(data, 2) ?? "" }
3654
- ];
3648
+ const text = typeof data === "string" ? data : stringifyJson(data, 2) ?? "";
3649
+ const content = [{ type: "text", text }];
3655
3650
  const warning = formatUnregisteredFactWarning(meta);
3656
3651
  if (warning) content.push({ type: "text", text: warning });
3657
3652
  const domainWarning = formatValueDomainMismatchWarning(meta);
@@ -3672,6 +3667,20 @@ function paginationParams(page, size) {
3672
3667
  if (size !== void 0) params.size = String(size);
3673
3668
  return params;
3674
3669
  }
3670
+ var MAX_EXPORT_CHARS = 1e5;
3671
+ var formatCount = (count) => count.toLocaleString("en-US");
3672
+ var EXPORT_LIMIT_NOTE = `An export longer than ${formatCount(MAX_EXPORT_CHARS)} characters is refused.`;
3673
+ function limitExport(result, command, narrower) {
3674
+ if (result.isError) return result;
3675
+ const length = result.content.reduce((sum, block) => sum + block.text.length, 0);
3676
+ if (length <= MAX_EXPORT_CHARS) return result;
3677
+ const text = [
3678
+ `The export is ${formatCount(length)} characters long, and this tool returns at most ${formatCount(MAX_EXPORT_CHARS)}.`,
3679
+ narrower,
3680
+ `To get the whole file, download it from the LexQ console (${CONSOLE_URL}) or with \`${command}\`.`
3681
+ ].filter(Boolean).join(" ");
3682
+ return { content: [{ type: "text", text }], isError: true };
3683
+ }
3675
3684
  function formatUnregisteredFactWarning(meta) {
3676
3685
  const facts = meta?.unregisteredFacts ?? [];
3677
3686
  if (facts.length === 0) return null;
@@ -3683,9 +3692,9 @@ function formatUnregisteredFactWarning(meta) {
3683
3692
  return ` \u2022 ${f.key} (ambiguous) \u2014 pick a type (${choices}), then lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: <chosen> })`;
3684
3693
  });
3685
3694
  return [
3686
- `\u26A0 The saved rule references ${facts.length} fact(s) not defined in the schema. The rule was saved \u2014 this does not block \u2014 but undefined facts skip type validation and the dry-run requirements analyzer.`,
3695
+ `\u26A0 The saved rule references ${facts.length} fact(s) not defined in the schema. The rule was saved, but the engine does not check request values for these keys against a type or value domain, and lexq_requirements lists them with type UNKNOWN.`,
3687
3696
  ...lines,
3688
- `Register them now so the rule validates as intended, or call lexq_facts_unregistered to review the version's full list.`
3697
+ `Register them before the version is published to have those values checked, or call lexq_facts_unregistered to review the version's full list.`
3689
3698
  ].join("\n");
3690
3699
  }
3691
3700
 
@@ -3711,9 +3720,9 @@ function registerAnalyticsTools(server, callApi) {
3711
3720
  "lexq_dry_run",
3712
3721
  {
3713
3722
  title: "Dry Run",
3714
- annotations: { readOnlyHint: true },
3723
+ annotations: READ_ONLY,
3715
3724
  description: dedent15`
3716
- Execute a single dry run against a version. Tests how rules evaluate given input facts without side effects.
3725
+ Execute a single dry run against a version. Tests how rules evaluate given input facts without side effects. ${DRY_RUN_LIMIT_NOTE}
3717
3726
 
3718
3727
  Returns:
3719
3728
  inputFacts — normalized input facts
@@ -3722,8 +3731,7 @@ function registerAnalyticsTools(server, callApi) {
3722
3731
  executionTraces — per-rule match status
3723
3732
  decisionTraces — per-rule decision (SELECTED / NO_MATCH / BLOCKED / etc.)
3724
3733
 
3725
- Example input: { "facts": { "paymentAmount": 100000, "customerTier": "VIP" } }
3726
- Always dry-run before publishing to validate rule behavior.`,
3734
+ Example input: { "facts": { "paymentAmount": 100000, "customerTier": "VIP" } }`,
3727
3735
  inputSchema: z.object({
3728
3736
  versionId: z.string().uuid().describe("Policy version ID to test against"),
3729
3737
  facts: z.string().describe('JSON string of facts object, e.g. {"paymentAmount":100000}'),
@@ -3741,9 +3749,9 @@ function registerAnalyticsTools(server, callApi) {
3741
3749
  "lexq_dry_run_compare",
3742
3750
  {
3743
3751
  title: "Dry Run Compare",
3744
- annotations: { readOnlyHint: true },
3752
+ annotations: READ_ONLY,
3745
3753
  description: dedent15`
3746
- Compare dry run results between two versions using the same input facts. Useful for validating changes.
3754
+ Compare dry run results between two versions using the same input facts. Useful for validating changes. ${DRY_RUN_LIMIT_NOTE}
3747
3755
 
3748
3756
  Returns:
3749
3757
  resultA / resultB — full DryRunResponse for each version
@@ -3766,7 +3774,7 @@ function registerAnalyticsTools(server, callApi) {
3766
3774
  "lexq_requirements",
3767
3775
  {
3768
3776
  title: "Analyze Requirements",
3769
- annotations: { readOnlyHint: true },
3777
+ annotations: READ_ONLY,
3770
3778
  description: "Analyze which input facts a version requires. Returns required keys, types, and an example request body.",
3771
3779
  inputSchema: z.object({
3772
3780
  groupId: z.string().uuid().describe("Policy group ID"),
@@ -3779,10 +3787,13 @@ function registerAnalyticsTools(server, callApi) {
3779
3787
  "lexq_simulation_start",
3780
3788
  {
3781
3789
  title: "Start Simulation",
3782
- annotations: { readOnlyHint: false, destructiveHint: false },
3790
+ annotations: DESTRUCTIVE,
3783
3791
  description: dedent15`
3784
3792
  Start an Impact Simulation against historical, uploaded, or inline data.
3785
3793
 
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
+
3786
3797
  dataset.type and dataset.source are BOTH required, and must be paired:
3787
3798
  HISTORICAL → source EXECUTION_LOGS, with dataset.from / dataset.to (yyyy-MM-dd, whole days
3788
3799
  in the organization time zone, both inclusive)
@@ -3823,7 +3834,7 @@ function registerAnalyticsTools(server, callApi) {
3823
3834
  "lexq_simulation_status",
3824
3835
  {
3825
3836
  title: "Simulation Status",
3826
- annotations: { readOnlyHint: true },
3837
+ annotations: READ_ONLY,
3827
3838
  description: "Get simulation status and results. Poll until status is COMPLETED or FAILED.",
3828
3839
  inputSchema: z.object({
3829
3840
  simulationId: z.string().uuid().describe("Simulation ID")
@@ -3835,7 +3846,7 @@ function registerAnalyticsTools(server, callApi) {
3835
3846
  "lexq_simulation_list",
3836
3847
  {
3837
3848
  title: "List Simulations",
3838
- annotations: { readOnlyHint: true },
3849
+ annotations: READ_ONLY,
3839
3850
  description: "List simulation history with optional filters.",
3840
3851
  inputSchema: z.object({
3841
3852
  page: z.number().int().min(0).default(0).describe("Page number"),
@@ -3857,7 +3868,7 @@ function registerAnalyticsTools(server, callApi) {
3857
3868
  "lexq_simulation_cancel",
3858
3869
  {
3859
3870
  title: "Cancel Simulation",
3860
- annotations: { readOnlyHint: false, destructiveHint: true },
3871
+ annotations: DESTRUCTIVE,
3861
3872
  description: "Cancel a running or pending simulation.",
3862
3873
  inputSchema: z.object({
3863
3874
  simulationId: z.string().uuid().describe("Simulation ID")
@@ -3869,22 +3880,25 @@ function registerAnalyticsTools(server, callApi) {
3869
3880
  "lexq_simulation_export",
3870
3881
  {
3871
3882
  title: "Export Simulation",
3872
- annotations: { readOnlyHint: true },
3873
- description: "Export simulation results as JSON or CSV. Returns the raw data.",
3883
+ annotations: READ_ONLY,
3884
+ description: `Export simulation results as JSON or CSV. Returns the raw data. ${EXPORT_LIMIT_NOTE}`,
3874
3885
  inputSchema: z.object({
3875
3886
  simulationId: z.string().uuid().describe("Simulation ID"),
3876
3887
  format: z.enum(EXPORT_FORMATS).default("json").describe("Export format")
3877
3888
  })
3878
3889
  },
3879
- async ({ simulationId, format }) => callApi("GET", `analytics/simulations/${simulationId}/export`, {
3880
- params: { format }
3881
- })
3890
+ async ({ simulationId, format }) => limitExport(
3891
+ await callApi("GET", `analytics/simulations/${simulationId}/export`, {
3892
+ params: { format }
3893
+ }),
3894
+ `lexq analytics simulation export --id ${simulationId} --as ${format} --output <file>`
3895
+ )
3882
3896
  );
3883
3897
  server.registerTool(
3884
3898
  "lexq_dataset_upload",
3885
3899
  {
3886
3900
  title: "Upload Dataset",
3887
- annotations: { readOnlyHint: false, destructiveHint: false },
3901
+ annotations: ADDITIVE,
3888
3902
  description: dedent15`
3889
3903
  Upload inline CSV or JSON content as a simulation dataset.
3890
3904
  The content is uploaded to S3 and a path is returned in the "path" field.
@@ -3927,7 +3941,7 @@ function registerAnalyticsTools(server, callApi) {
3927
3941
  "lexq_dataset_template",
3928
3942
  {
3929
3943
  title: "Download Dataset Template",
3930
- annotations: { readOnlyHint: true },
3944
+ annotations: READ_ONLY,
3931
3945
  description: "Generate a sample CSV or JSON template based on the required facts of a version. Use this to understand the expected data format before uploading a dataset.",
3932
3946
  inputSchema: z.object({
3933
3947
  groupId: z.string().uuid().describe("Policy group ID"),
@@ -3984,7 +3998,7 @@ function registerLocalTools(server) {
3984
3998
  "lexq_dataset_upload_file",
3985
3999
  {
3986
4000
  title: "Upload Dataset File",
3987
- annotations: { readOnlyHint: false, destructiveHint: false },
4001
+ annotations: ADDITIVE,
3988
4002
  description: dedent16`
3989
4003
  Upload a CSV or JSON file from this computer as a simulation dataset, up to ${MAX_DATASET_MB} MB.
3990
4004
  The server reads the file from disk, so its content never passes through the conversation.
@@ -4043,7 +4057,7 @@ function registerStatusTools(server, callApi) {
4043
4057
  "lexq_whoami",
4044
4058
  {
4045
4059
  title: "Who Am I",
4046
- annotations: { readOnlyHint: true },
4060
+ annotations: READ_ONLY,
4047
4061
  description: 'Show current authentication info (tenant ID, user ID, role) and tenantTimezone, the organization time zone that every yyyy-MM-dd date filter uses. Work out "today" or "yesterday" in that zone, not in UTC.',
4048
4062
  inputSchema: z3.object({})
4049
4063
  },
@@ -4058,7 +4072,7 @@ function registerGroupTools(server, callApi) {
4058
4072
  "lexq_groups_list",
4059
4073
  {
4060
4074
  title: "List Policy Groups",
4061
- annotations: { readOnlyHint: true },
4075
+ annotations: READ_ONLY,
4062
4076
  description: "List policy groups (tenant-wide, priority ASC). Deleted groups are not included.",
4063
4077
  inputSchema: z4.object({})
4064
4078
  },
@@ -4068,7 +4082,7 @@ function registerGroupTools(server, callApi) {
4068
4082
  "lexq_groups_get",
4069
4083
  {
4070
4084
  title: "Get Policy Group",
4071
- annotations: { readOnlyHint: true },
4085
+ annotations: READ_ONLY,
4072
4086
  description: "Get a single policy group by ID. Its currentVersionId and currentDeploymentId are what lexq_deploy_rollback expects, and previousVersionName is the version a rollback restores.",
4073
4087
  inputSchema: z4.object({
4074
4088
  groupId: z4.string().uuid().describe("Policy group ID")
@@ -4080,7 +4094,7 @@ function registerGroupTools(server, callApi) {
4080
4094
  "lexq_groups_create",
4081
4095
  {
4082
4096
  title: "Create Policy Group",
4083
- annotations: { readOnlyHint: false, destructiveHint: false },
4097
+ annotations: ADDITIVE,
4084
4098
  description: `Create a new policy group. Requires name. Priority is auto-assigned (appended last, tenant-wide); use lexq_groups_reorder to change order. Optionally set conflict resolution, activation group, and description. Policy groups that share an activationGroup form a cluster and must share the same activationMode / activationStrategy / executionLimit; executionLimit is how many of those groups run, not how many rules. A tenant can have at most ${MAX_GROUPS_PER_TENANT} groups that are not ARCHIVED; archive groups you no longer use to free a slot.`,
4085
4099
  inputSchema: z4.object({
4086
4100
  name: z4.string().describe("Group name (unique among non-ARCHIVED)"),
@@ -4111,7 +4125,7 @@ function registerGroupTools(server, callApi) {
4111
4125
  "lexq_groups_update",
4112
4126
  {
4113
4127
  title: "Update Policy Group",
4114
- annotations: { readOnlyHint: false, destructiveHint: true },
4128
+ annotations: DESTRUCTIVE,
4115
4129
  description: "Update a policy group. Only provided fields are updated; omitted fields remain unchanged. A deleted group cannot be updated (409 P-059).",
4116
4130
  inputSchema: z4.object({
4117
4131
  groupId: z4.string().uuid().describe("Policy group ID"),
@@ -4136,7 +4150,7 @@ function registerGroupTools(server, callApi) {
4136
4150
  "lexq_groups_delete",
4137
4151
  {
4138
4152
  title: "Delete Policy Group",
4139
- annotations: { readOnlyHint: false, destructiveHint: true },
4153
+ annotations: DESTRUCTIVE,
4140
4154
  description: `Delete (archive) a policy group. A deployed group cannot be deleted (P-020); undeploy it first with lexq_deploy_undeploy, which also ends a running A/B test. This cannot be undone. The group no longer appears in lexq_groups_list and its name can be reused right away. Calls that change it (settings, versions, rules, starting an A/B test, publish, deploy, rollback, scheduling a deployment) return 409 P-059. If it was ever executed, deployed, or replayed, it is kept for audit; otherwise it is permanently deleted after ${PURGE_AFTER_DAYS} days.`,
4141
4155
  inputSchema: z4.object({
4142
4156
  groupId: z4.string().uuid().describe("Policy group ID")
@@ -4148,7 +4162,7 @@ function registerGroupTools(server, callApi) {
4148
4162
  "lexq_groups_reorder",
4149
4163
  {
4150
4164
  title: "Reorder Policy Groups",
4151
- annotations: { readOnlyHint: false, destructiveHint: true },
4165
+ annotations: DESTRUCTIVE,
4152
4166
  description: "Reorder policy groups by priority. Priority is tenant-wide and flat (1...N continuous); array index 0 = priority 1 (highest precedence). activationGroup is not affected \u2014 this only changes priority.",
4153
4167
  inputSchema: z4.object({
4154
4168
  groupIds: z4.array(z4.string().uuid()).describe("Group IDs in desired priority order (index 0 = priority 1)")
@@ -4166,8 +4180,8 @@ function registerGroupTools(server, callApi) {
4166
4180
  "lexq_ab_test_start",
4167
4181
  {
4168
4182
  title: "Start A/B Test",
4169
- annotations: { readOnlyHint: false, destructiveHint: false },
4170
- description: "Start an A/B test on a policy group. Requires a challenger version ID and traffic rate. The challenger must be a published version of the same group; a version from another group is rejected with P-016. The split is computed from context.trafficKey on each execution request; requests that omit it never reach the challenger and the test stays at 0%.",
4183
+ annotations: DESTRUCTIVE,
4184
+ description: "Start an A/B test on a policy group. Requires a challenger version ID and traffic rate. The challenger must be a published version of the same group; a version from another group is rejected with P-016. The split is computed from context.trafficKey on each execution request; requests that omit it never reach the challenger and the test stays at 0%. Starting cancels the group's pending scheduled deployment. Starting while a test is already running replaces its challenger and traffic rate.",
4171
4185
  inputSchema: z4.object({
4172
4186
  groupId: z4.string().uuid().describe("Policy group ID"),
4173
4187
  testVersionId: z4.string().uuid().describe("Challenger version ID. Must be a published version of this group"),
@@ -4182,7 +4196,7 @@ function registerGroupTools(server, callApi) {
4182
4196
  "lexq_ab_test_stop",
4183
4197
  {
4184
4198
  title: "Stop A/B Test",
4185
- annotations: { readOnlyHint: false, destructiveHint: true },
4199
+ annotations: DESTRUCTIVE,
4186
4200
  description: "Stop a running A/B test. All traffic is restored to the control (current) version.",
4187
4201
  inputSchema: z4.object({
4188
4202
  groupId: z4.string().uuid().describe("Policy group ID")
@@ -4194,7 +4208,7 @@ function registerGroupTools(server, callApi) {
4194
4208
  "lexq_ab_test_adjust",
4195
4209
  {
4196
4210
  title: "Adjust A/B Test",
4197
- annotations: { readOnlyHint: false, destructiveHint: true },
4211
+ annotations: DESTRUCTIVE,
4198
4212
  description: "Adjust traffic rate of a running A/B test.",
4199
4213
  inputSchema: z4.object({
4200
4214
  groupId: z4.string().uuid().describe("Policy group ID"),
@@ -4214,7 +4228,7 @@ function registerVersionTools(server, callApi) {
4214
4228
  "lexq_versions_list",
4215
4229
  {
4216
4230
  title: "List Policy Versions",
4217
- annotations: { readOnlyHint: true },
4231
+ annotations: READ_ONLY,
4218
4232
  description: "List all versions of a policy group.",
4219
4233
  inputSchema: z5.object({
4220
4234
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4230,7 +4244,7 @@ function registerVersionTools(server, callApi) {
4230
4244
  "lexq_versions_get",
4231
4245
  {
4232
4246
  title: "Get Policy Version",
4233
- annotations: { readOnlyHint: true },
4247
+ annotations: READ_ONLY,
4234
4248
  description: "Get a single version by ID, including its rules and fact requirements.",
4235
4249
  inputSchema: z5.object({
4236
4250
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4243,7 +4257,7 @@ function registerVersionTools(server, callApi) {
4243
4257
  "lexq_versions_create",
4244
4258
  {
4245
4259
  title: "Create Policy Version",
4246
- annotations: { readOnlyHint: false, destructiveHint: false },
4260
+ annotations: ADDITIVE,
4247
4261
  description: `Create a new DRAFT version in a policy group. Optionally provide a commit message and effective date range. A group can hold at most ${MAX_DRAFT_VERSIONS_PER_GROUP} DRAFT versions at a time; published versions do not count. Publish or delete drafts to make room.`,
4248
4262
  inputSchema: z5.object({
4249
4263
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4258,7 +4272,7 @@ function registerVersionTools(server, callApi) {
4258
4272
  "lexq_versions_update",
4259
4273
  {
4260
4274
  title: "Update Policy Version",
4261
- annotations: { readOnlyHint: false, destructiveHint: true },
4275
+ annotations: DESTRUCTIVE,
4262
4276
  description: "Update a DRAFT version. Only DRAFT versions can be modified. Omitted fields keep their current values. To remove an end of the effective window, set clearEffectiveFrom or clearEffectiveTo to true instead of sending a value.",
4263
4277
  inputSchema: z5.object({
4264
4278
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4266,7 +4280,9 @@ function registerVersionTools(server, callApi) {
4266
4280
  commitMessage: z5.string().optional().describe("New commit message"),
4267
4281
  effectiveFrom: z5.string().optional().describe(`Effective start, ${INSTANT} Omit it to keep the current start.`),
4268
4282
  effectiveTo: z5.string().optional().describe(`Effective end, ${INSTANT} Omit it to keep the current end.`),
4269
- clearEffectiveFrom: z5.boolean().optional().describe("Set to true to remove the effective start. Do not send effectiveFrom with it."),
4283
+ clearEffectiveFrom: z5.boolean().optional().describe(
4284
+ "Set to true to remove the effective start. Do not send effectiveFrom with it."
4285
+ ),
4270
4286
  clearEffectiveTo: z5.boolean().optional().describe("Set to true to remove the effective end. Do not send effectiveTo with it.")
4271
4287
  })
4272
4288
  },
@@ -4276,7 +4292,7 @@ function registerVersionTools(server, callApi) {
4276
4292
  "lexq_versions_delete",
4277
4293
  {
4278
4294
  title: "Delete Policy Version",
4279
- annotations: { readOnlyHint: false, destructiveHint: true },
4295
+ annotations: DESTRUCTIVE,
4280
4296
  description: "Delete a DRAFT version. Only DRAFT versions can be deleted.",
4281
4297
  inputSchema: z5.object({
4282
4298
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4289,7 +4305,7 @@ function registerVersionTools(server, callApi) {
4289
4305
  "lexq_versions_clone",
4290
4306
  {
4291
4307
  title: "Clone Policy Version",
4292
- annotations: { readOnlyHint: false, destructiveHint: false },
4308
+ annotations: ADDITIVE,
4293
4309
  description: `Clone an existing version to create a new DRAFT. Useful when the source version is already published. The new DRAFT counts toward the limit of ${MAX_DRAFT_VERSIONS_PER_GROUP} DRAFT versions per group, and a source with more than ${MAX_RULES_PER_VERSION} rules cannot be cloned.`,
4294
4310
  inputSchema: z5.object({
4295
4311
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4313,7 +4329,7 @@ function registerRuleTools(server, callApi) {
4313
4329
  "lexq_rules_list",
4314
4330
  {
4315
4331
  title: "List Rules",
4316
- annotations: { readOnlyHint: true },
4332
+ annotations: READ_ONLY,
4317
4333
  description: "List all rules in a version (priority ASC). Returns summary with conditionSummary and actionSummary.",
4318
4334
  inputSchema: z6.object({
4319
4335
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4326,7 +4342,7 @@ function registerRuleTools(server, callApi) {
4326
4342
  "lexq_rules_get",
4327
4343
  {
4328
4344
  title: "Get Rule Detail",
4329
- annotations: { readOnlyHint: true },
4345
+ annotations: READ_ONLY,
4330
4346
  description: "Get full rule detail including condition tree and action definitions.",
4331
4347
  inputSchema: z6.object({
4332
4348
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4340,87 +4356,39 @@ function registerRuleTools(server, callApi) {
4340
4356
  "lexq_rules_create",
4341
4357
  {
4342
4358
  title: "Create Rule",
4343
- annotations: { readOnlyHint: false, destructiveHint: false },
4359
+ annotations: ADDITIVE,
4344
4360
  description: dedent17`
4345
- Create a rule in a DRAFT version. Requires name, condition tree, and actions array. priority is auto-assigned (appended last); use lexq_rules_reorder to change order.
4346
-
4347
- Before creating rules with new fact keys, call lexq_facts_list to check existing facts.
4348
- If a required key is missing, ask the user to confirm the type, isRequired, and description
4349
- before calling lexq_facts_create — registering facts enables type validation, Console UI
4350
- autocomplete, and the dry-run requirements analyzer.
4361
+ Create a rule in a DRAFT version. It goes last in priority order; lexq_rules_reorder changes the order.
4351
4362
 
4352
- An action's targetVar (SET_FACT, MUTATE_FACT) must be a registered fact. Saving or
4353
- publishing a rule whose targetVar is not registered fails with ACT-032, so register it
4354
- with lexq_facts_create first. Keys a rule only reads (condition fields, refVar) may stay
4355
- unregistered.
4356
-
4357
- After saving, lexq_facts_unregistered lists any keys this version references but has not
4358
- defined (non-blocking, version-wide) — use it to decide what to register.
4363
+ An action's targetVar must be a registered fact (ACT-032 otherwise). Keys a rule only reads (condition fields, refVar) may stay unregistered.
4359
4364
 
4360
4365
  Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: ${oneOf(LogicalOperator)}, children: [...] }
4361
4366
  Value types: ${ValueType.join(", ")}
4362
- Limits: a condition tree may hold at most ${MAX_CONDITIONS_PER_RULE} conditions (SINGLE nodes; GROUP nodes do not count)
4363
- and nest at most ${MAX_CONDITION_DEPTH} levels below the root. A version holds at most ${MAX_RULES_PER_VERSION} rules.
4364
-
4365
- Operators are constrained by the LEFT fact's type (from lexq_facts_list). Using one outside
4366
- its type is rejected by the server — check the fact type before choosing an operator.
4367
- - STRING fact: EQUALS, NOT_EQUALS, CONTAINS, IN, NOT_IN
4368
- - NUMBER fact: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
4369
- - BOOLEAN fact: EQUALS, NOT_EQUALS
4370
- - LIST_* fact: HAS_ANY, HAS_ALL, HAS_NONE (only these)
4371
-
4372
- HAS_* query list-typed facts. Value is always an array whose element type matches the fact:
4373
- - HAS_ANY: fact has at least one of the given values
4374
- - HAS_ALL: fact has all of the given values
4375
- - HAS_NONE: fact has none of the given values
4367
+ Limits: ${MAX_CONDITIONS_PER_RULE} SINGLE conditions per rule (GROUP nodes do not count), ${MAX_CONDITION_DEPTH} levels of nesting below the root, ${MAX_RULES_PER_VERSION} rules per version.
4368
+
4369
+ Operators that work on each fact type:
4370
+ - STRING: EQUALS, NOT_EQUALS, CONTAINS (substring), IN, NOT_IN
4371
+ - NUMBER: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
4372
+ - BOOLEAN: EQUALS, NOT_EQUALS
4373
+ - LIST_STRING, LIST_NUMBER: HAS_ANY, HAS_ALL, HAS_NONE
4374
+ IN and NOT_IN test whether the fact's value is in the array. HAS_ANY, HAS_ALL and HAS_NONE match when the fact's list holds at least one, all, or none of the array's values. The array holds values of the fact's type, or for a list fact, of its element type.
4376
4375
  Example: { "type": "SINGLE", "field": "userTags", "operator": "HAS_ANY", "value": ["VIP","GOLD"], "valueType": "LIST_STRING" }
4377
4376
 
4378
- Do NOT use CONTAINS on a list fact — CONTAINS is substring match on STRING facts only.
4379
- IN is the mirror of HAS_*: IN takes a scalar fact with a list value; HAS_* takes lists on both sides.
4380
-
4381
4377
  Actions: [{ type, parameters }]
4382
-
4383
- Action parameter schemas:
4384
- - MUTATE_FACT: { targetVar: string, operator: ${oneOf(MutateOperator)}, method: ${oneOf(CalculationMethod)}, operand: number, refVar?: string, rounding?: RoundingOption }
4385
- targetVar is the fact this action reads and writes. It must be a registered fact
4386
- (ACT-032 otherwise), and the request must send it (P-015 otherwise). Its value must be
4387
- a number when the action runs.
4388
- A missing required fact throws (no 0 default).
4389
- operand is the arithmetic operand; the unit is dictated by method (percent when
4390
- PERCENTAGE, absolute amount when AMOUNT). Ranges are not constrained — negative values
4391
- and >100 percentages are valid (refunds, surcharges).
4392
- refVar is the base for percentage calculation and is OPTIONAL — omit it to use targetVar
4393
- itself. It is only meaningful in PERCENTAGE × {ASSIGN, ADD, SUB}; specifying it in any
4394
- other cell is an error. Use it when the base differs from the target, e.g.
4395
- "points += orderTotal × 5%" → { targetVar: "points", refVar: "orderTotal",
4396
- operator: "ADD", method: "PERCENTAGE", operand: 5 }.
4397
- operator × method matrix:
4398
- ASSIGN targetVar = operand | targetVar = refVar × operand/100
4399
- ADD targetVar += operand | targetVar += refVar × operand/100
4400
- SUB targetVar -= operand | targetVar -= refVar × operand/100
4401
- MUL targetVar *= operand | targetVar *= (operand/100 + 1)
4402
- DIV targetVar /= operand | invalid
4403
- Constraints: DIV + PERCENTAGE is invalid (use MUL with the inverse). DIV + AMOUNT
4404
- requires operand !== 0.
4405
- - SET_FACT: { targetVar: string, value: string|number|boolean } targetVar must be a
4406
- registered fact (ACT-032 otherwise). The request does not have to send it: SET_FACT is
4407
- the only action whose targetVar may be absent from the input facts. MUTATE_FACT requires
4408
- its target to be present in the input facts.
4409
- - BLOCK: { reason?: string } Records a rejection decision. It does NOT halt rule execution —
4410
- subsequent actions and subsequent winning rules still run. Enforcement is the caller's
4411
- responsibility; the decision surfaces as isBlocked (true) and blockReason (reason, or
4412
- "Policy Violation" when omitted).
4413
- Only BLOCK writes those two keys: saving or publishing a rule whose SET_FACT or
4414
- MUTATE_FACT targets either one fails with ACT-033.
4415
-
4416
- Evaluation order: every rule's condition is checked against the input facts before any
4417
- action runs, so a condition never sees a value an action writes in the same request. Then
4418
- the actions of the winning rules run in priority order. Each action works on the values
4419
- the actions before it left, in its own rule or an earlier one. To test a value an action
4420
- writes, send that value as an input fact in a second request. If a rule should run
4421
- whenever another one does, put its actions in that rule instead.
4422
-
4423
- RoundingOption (optional, MUTATE_FACT only): { scale: integer (0..${MAX_ROUNDING_SCALE}), mode?: ${oneOf(RoundingPolicy)} } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
4378
+ - MUTATE_FACT: { targetVar, operator: ${oneOf(MutateOperator)}, method: ${oneOf(CalculationMethod)}, operand: number, refVar?, rounding? }
4379
+ Reads and writes targetVar. The request must send it (P-015 otherwise), and it must hold a number when the action runs. operand may be negative, and a percent may be over 100.
4380
+ AMOUNT PERCENTAGE (base = refVar, or targetVar when omitted)
4381
+ ASSIGN targetVar = operand targetVar = base × operand/100
4382
+ ADD targetVar += operand targetVar += base × operand/100
4383
+ SUB targetVar -= operand targetVar -= base × operand/100
4384
+ MUL targetVar *= operand targetVar *= (operand/100 + 1)
4385
+ DIV targetVar /= operand invalid
4386
+ refVar is allowed only with PERCENTAGE and ASSIGN, ADD or SUB. DIV with AMOUNT needs a nonzero operand.
4387
+ rounding: { scale: integer (0..${MAX_ROUNDING_SCALE}), mode?: ${oneOf(RoundingPolicy)} }. mode defaults to HALF_UP. Without rounding, the result keeps full precision.
4388
+ - SET_FACT: { targetVar, value: string|number|boolean }. The request does not have to send targetVar.
4389
+ - BLOCK: { reason? } sets isBlocked to true and blockReason to the reason, or "Policy Violation" without one. It does not stop later actions or rules; the caller enforces the block. Only BLOCK writes these two keys: a SET_FACT or MUTATE_FACT targeting either is rejected (ACT-033).
4390
+
4391
+ Every condition is checked against the input facts before any action runs, so a condition never sees a value an action writes in the same request. The winning rules' actions then run in priority order, each on the values the actions before it left.
4424
4392
  `,
4425
4393
  inputSchema: z6.object({
4426
4394
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4439,7 +4407,7 @@ function registerRuleTools(server, callApi) {
4439
4407
  "lexq_rules_update",
4440
4408
  {
4441
4409
  title: "Update Rule",
4442
- annotations: { readOnlyHint: false, destructiveHint: true },
4410
+ annotations: DESTRUCTIVE,
4443
4411
  description: `Update an existing rule in a DRAFT version. Only provided fields are changed. A new condition tree must stay within ${MAX_CONDITIONS_PER_RULE} conditions (SINGLE nodes) and ${MAX_CONDITION_DEPTH} levels of nesting below the root. Saving re-checks every action in the rule, including ones you did not change, and fails with ACT-032 if any action's targetVar is not a registered fact.`,
4444
4412
  inputSchema: z6.object({
4445
4413
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4461,28 +4429,21 @@ function registerRuleTools(server, callApi) {
4461
4429
  "lexq_rules_delete",
4462
4430
  {
4463
4431
  title: "Delete Rule",
4464
- annotations: { readOnlyHint: false, destructiveHint: true },
4432
+ annotations: DESTRUCTIVE,
4465
4433
  description: "Delete a rule from a DRAFT version.",
4466
4434
  inputSchema: z6.object({
4467
4435
  groupId: z6.string().uuid().describe("Policy group ID"),
4468
4436
  versionId: z6.string().uuid().describe("Version ID"),
4469
- ruleId: z6.string().uuid().describe("Rule ID"),
4470
- force: z6.boolean().default(false).describe("Skip confirmation (for the last rule in a version)")
4437
+ ruleId: z6.string().uuid().describe("Rule ID")
4471
4438
  })
4472
4439
  },
4473
- async ({ groupId, versionId, ruleId, force }) => {
4474
- const params = {};
4475
- if (force) params.force = "true";
4476
- return callApi("DELETE", `policy-groups/${groupId}/versions/${versionId}/rules/${ruleId}`, {
4477
- params
4478
- });
4479
- }
4440
+ async ({ groupId, versionId, ruleId }) => callApi("DELETE", `policy-groups/${groupId}/versions/${versionId}/rules/${ruleId}`)
4480
4441
  );
4481
4442
  server.registerTool(
4482
4443
  "lexq_rules_reorder",
4483
4444
  {
4484
4445
  title: "Reorder Rules",
4485
- annotations: { readOnlyHint: false, destructiveHint: true },
4446
+ annotations: DESTRUCTIVE,
4486
4447
  description: "Reorder rules by specifying rule IDs in desired order. Priorities are assigned 1...N (1-based, continuous); array index 0 = priority 1 (highest precedence).",
4487
4448
  inputSchema: z6.object({
4488
4449
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4504,7 +4465,7 @@ function registerRuleTools(server, callApi) {
4504
4465
  "lexq_rules_toggle",
4505
4466
  {
4506
4467
  title: "Toggle Rule",
4507
- annotations: { readOnlyHint: false, destructiveHint: true },
4468
+ annotations: DESTRUCTIVE,
4508
4469
  description: "Enable or disable a rule without deleting it.",
4509
4470
  inputSchema: z6.object({
4510
4471
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4527,13 +4488,14 @@ var FACT_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
4527
4488
 
4528
4489
  // src/mcp/tools/facts.ts
4529
4490
  var VALUE_TYPES = ValueType;
4491
+ var IS_REQUIRED_NOTE = "isRequired is stored on the definition only; the engine does not read it.";
4530
4492
  function registerFactTools(server, callApi) {
4531
4493
  server.registerTool(
4532
4494
  "lexq_facts_list",
4533
4495
  {
4534
4496
  title: "List Fact Definitions",
4535
- annotations: { readOnlyHint: true },
4536
- description: "List all fact definitions (input variable schema). Shows key, type, required, and PII status. Always check this before creating rules.",
4497
+ annotations: READ_ONLY,
4498
+ description: `List all fact definitions (input variable schema): key, name, type, description, PII and system flags, and value domain. ${IS_REQUIRED_NOTE}`,
4537
4499
  inputSchema: z7.object({
4538
4500
  page: z7.number().int().min(0).default(0).describe("Page number"),
4539
4501
  size: z7.number().int().min(1).max(100).default(20).describe("Page size"),
@@ -4550,14 +4512,14 @@ function registerFactTools(server, callApi) {
4550
4512
  "lexq_facts_create",
4551
4513
  {
4552
4514
  title: "Create Fact Definition",
4553
- annotations: { readOnlyHint: false, destructiveHint: false },
4515
+ annotations: ADDITIVE,
4554
4516
  description: `Register a new input variable. Key starts with a letter, then letters, numbers, and underscores (e.g. paymentAmount). Casing is not enforced. Types: ${VALUE_TYPES.join(", ")}.`,
4555
4517
  inputSchema: z7.object({
4556
4518
  key: z7.string().regex(FACT_KEY_PATTERN).describe("Variable key. Any casing; must start with a letter."),
4557
4519
  name: z7.string().describe("Display name"),
4558
4520
  type: z7.enum(VALUE_TYPES).describe("Value type"),
4559
4521
  description: z7.string().optional().describe("Description"),
4560
- isRequired: z7.boolean().default(false).describe("Whether this fact is required for rule evaluation"),
4522
+ isRequired: z7.boolean().default(false).describe(IS_REQUIRED_NOTE),
4561
4523
  isPii: z7.boolean().default(false).describe(
4562
4524
  "Mark as PII \u2014 masked on every read surface, revealable only in the console (audited)"
4563
4525
  ),
@@ -4578,7 +4540,7 @@ function registerFactTools(server, callApi) {
4578
4540
  "lexq_facts_update",
4579
4541
  {
4580
4542
  title: "Update Fact Definition",
4581
- annotations: { readOnlyHint: false, destructiveHint: true },
4543
+ annotations: DESTRUCTIVE,
4582
4544
  description: "Update a fact definition. The key is immutable. The type can change only while no rule references the fact; if any does, the call fails with FD-007 and reports the count. Rules in deleted groups do not count. Only the fields you send are changed. System facts (isBlocked, blockReason) accept changes to name, description, PII, and the value domain; isRequired is ignored, and their type cannot change (FD-003).",
4583
4545
  inputSchema: z7.object({
4584
4546
  factId: z7.string().uuid().describe("Fact definition ID"),
@@ -4587,7 +4549,7 @@ function registerFactTools(server, callApi) {
4587
4549
  type: z7.enum(VALUE_TYPES).optional().describe(
4588
4550
  "Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule outside a deleted group references the fact."
4589
4551
  ),
4590
- isRequired: z7.boolean().optional().describe("Required flag"),
4552
+ isRequired: z7.boolean().optional().describe(IS_REQUIRED_NOTE),
4591
4553
  isPii: z7.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
4592
4554
  valueDomain: z7.object({
4593
4555
  allowedValues: z7.array(z7.union([z7.string(), z7.number()])).optional().describe(
@@ -4606,7 +4568,7 @@ function registerFactTools(server, callApi) {
4606
4568
  "lexq_facts_delete",
4607
4569
  {
4608
4570
  title: "Delete Fact Definition",
4609
- annotations: { readOnlyHint: false, destructiveHint: true },
4571
+ annotations: DESTRUCTIVE,
4610
4572
  description: "Delete a fact definition. System facts cannot be deleted. Neither can a fact that any rule references: that call fails with FD-006 and reports the count. Rules in deleted groups do not count. Remove the references first.",
4611
4573
  inputSchema: z7.object({
4612
4574
  factId: z7.string().uuid().describe("Fact definition ID")
@@ -4618,7 +4580,7 @@ function registerFactTools(server, callApi) {
4618
4580
  "lexq_facts_action_metadata",
4619
4581
  {
4620
4582
  title: "Get Action Runtime Fact Metadata",
4621
- annotations: { readOnlyHint: true },
4583
+ annotations: READ_ONLY,
4622
4584
  description: "Retrieve runtime fact requirements per Action type. For each Action, shows which input facts must be present in the execution payload \u2014 e.g. MUTATE_FACT always requires its targetVar fact, plus refVar when one is specified. The factRequired flag describes the FACT, not the parameter: refVar is an optional parameter, but if you specify it the named fact must exist. A required fact absent at runtime throws \u2014 the engine never defaults to 0. These facts must be sent in the request (P-015 otherwise), even when an earlier action in the same rule writes them. SET_FACT requires no input fact: it is the only action whose targetVar the request does not have to send, and that targetVar must be a registered fact (ACT-032). Static data, safe to cache in-session.",
4623
4585
  inputSchema: z7.object({})
4624
4586
  },
@@ -4628,8 +4590,8 @@ function registerFactTools(server, callApi) {
4628
4590
  "lexq_facts_unregistered",
4629
4591
  {
4630
4592
  title: "List Unregistered Facts",
4631
- annotations: { readOnlyHint: true },
4632
- description: "List facts referenced by a version's rules but not yet defined (read-only). Keys that rules only read do not block publish/deploy; a rule whose action targetVar is not a registered fact is rejected at save and publish with ACT-032. Version-wide: covers every rule in the version. Each entry carries the inferred type, suggested name, and where it is referenced (condition/action). Register them with lexq_facts_create to enable type validation and the dry-run requirements analyzer.",
4593
+ annotations: READ_ONLY,
4594
+ description: "List facts referenced by a version's rules but not yet defined (read-only). Keys that rules only read do not block publish/deploy; a rule whose action targetVar is not a registered fact is rejected at save and publish with ACT-032. Version-wide: covers every rule in the version. Each entry carries the inferred type, suggested name, and where it is referenced (condition/action). Registering one with lexq_facts_create before the version is published makes the engine check request values for it against its type (P-060) and any value domain (P-052).",
4633
4595
  inputSchema: z7.object({
4634
4596
  groupId: z7.string().uuid().describe("Policy group ID"),
4635
4597
  versionId: z7.string().uuid().describe("Version ID")
@@ -4641,16 +4603,20 @@ function registerFactTools(server, callApi) {
4641
4603
  "lexq_facts_export",
4642
4604
  {
4643
4605
  title: "Export Fact Catalog",
4644
- annotations: { readOnlyHint: true },
4645
- description: "Export the fact catalog. The two formats carry different things: CSV is the catalog as it stands, system facts included, for reading in a spreadsheet; JSON matches the shape that batch create accepts, so it can be fed straight back in, which is why it leaves out the fields that endpoint does not take. Returns the file contents as text.",
4606
+ annotations: READ_ONLY,
4607
+ description: `Export the fact catalog. The two formats carry different things: CSV is the catalog as it stands, system facts included, for reading in a spreadsheet, without value domains; JSON lists each definition with only the fields a new definition takes (key, name, type, description, isRequired, isPii, valueDomain), without IDs. Returns the file contents as text. ${EXPORT_LIMIT_NOTE}`,
4646
4608
  inputSchema: z7.object({
4647
4609
  format: z7.enum(EXPORT_FORMATS).default("csv").describe("Export format"),
4648
4610
  keyword: z7.string().optional().describe("Filter by key or name")
4649
4611
  })
4650
4612
  },
4651
- async ({ format, keyword }) => callApi("GET", "schema/facts/export", {
4652
- params: keyword ? { format, keyword } : { format }
4653
- })
4613
+ async ({ format, keyword }) => limitExport(
4614
+ await callApi("GET", "schema/facts/export", {
4615
+ params: keyword ? { format, keyword } : { format }
4616
+ }),
4617
+ `lexq facts export --as ${format} --output <file>`,
4618
+ "Pass keyword to export fewer facts."
4619
+ )
4654
4620
  );
4655
4621
  }
4656
4622
 
@@ -4662,7 +4628,7 @@ function registerDeployTools(server, callApi) {
4662
4628
  "lexq_deploy_publish",
4663
4629
  {
4664
4630
  title: "Publish Version",
4665
- annotations: { readOnlyHint: false, destructiveHint: false },
4631
+ annotations: DESTRUCTIVE,
4666
4632
  description: `Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule. Undefined facts that rules only read do not block publishing; call lexq_facts_unregistered first to review them. Publishing fails if an action's targetVar is not a registered fact (ACT-032), if the version's compiled rules exceed ${MAX_SNAPSHOT_BYTES / 1024} KB, or if its effective end has already passed (P-058); move the end into the future with lexq_versions_update first.`,
4667
4633
  inputSchema: z8.object({
4668
4634
  groupId: z8.string().uuid().describe("Policy group ID"),
@@ -4676,7 +4642,7 @@ function registerDeployTools(server, callApi) {
4676
4642
  "lexq_deploy_live",
4677
4643
  {
4678
4644
  title: "Deploy to Live",
4679
- annotations: { readOnlyHint: false, destructiveHint: true },
4645
+ annotations: DESTRUCTIVE,
4680
4646
  description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately. Versions whose effective start date has not arrived are rejected (P-037) \u2014 use lexq_deploy_schedule for those. Versions whose effective end has passed are rejected (P-058). Undefined facts do not block deployment; use lexq_facts_unregistered to review what the version references but has not defined.",
4681
4647
  inputSchema: z8.object({
4682
4648
  groupId: z8.string().uuid().describe("Policy group ID"),
@@ -4692,7 +4658,7 @@ function registerDeployTools(server, callApi) {
4692
4658
  "lexq_deploy_rollback",
4693
4659
  {
4694
4660
  title: "Rollback Deployment",
4695
- annotations: { readOnlyHint: false, destructiveHint: true },
4661
+ annotations: DESTRUCTIVE,
4696
4662
  description: dedent18`
4697
4663
  Roll the group back to the version that was live before the current one. Call lexq_groups_get first: currentVersionName is live now, previousVersionName is what a rollback restores, and a rollback is rejected while canRollback is false. A rollback needs a previous version that is still ACTIVE and whose effective end has not passed (P-058).
4698
4664
 
@@ -4713,7 +4679,7 @@ function registerDeployTools(server, callApi) {
4713
4679
  "lexq_deploy_undeploy",
4714
4680
  {
4715
4681
  title: "Undeploy",
4716
- annotations: { readOnlyHint: false, destructiveHint: true },
4682
+ annotations: DESTRUCTIVE,
4717
4683
  description: "Remove the live version from traffic. The version stays ACTIVE but no longer serves requests.",
4718
4684
  inputSchema: z8.object({
4719
4685
  groupId: z8.string().uuid().describe("Policy group ID"),
@@ -4728,7 +4694,7 @@ function registerDeployTools(server, callApi) {
4728
4694
  "lexq_deploy_schedule",
4729
4695
  {
4730
4696
  title: "Schedule Deployment",
4731
- annotations: { readOnlyHint: false, destructiveHint: false },
4697
+ annotations: DESTRUCTIVE,
4732
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).",
4733
4699
  inputSchema: z8.object({
4734
4700
  groupId: z8.string().uuid().describe("Policy group ID"),
@@ -4744,7 +4710,7 @@ function registerDeployTools(server, callApi) {
4744
4710
  "lexq_deploy_unschedule",
4745
4711
  {
4746
4712
  title: "Cancel Scheduled Deployment",
4747
- annotations: { readOnlyHint: false, destructiveHint: true },
4713
+ annotations: DESTRUCTIVE,
4748
4714
  description: "Cancel the pending scheduled deployment for a group. The version itself is not affected. Fails with P-039 if no pending schedule exists.",
4749
4715
  inputSchema: z8.object({
4750
4716
  groupId: z8.string().uuid().describe("Policy group ID")
@@ -4756,7 +4722,7 @@ function registerDeployTools(server, callApi) {
4756
4722
  "lexq_deploy_schedules",
4757
4723
  {
4758
4724
  title: "List Scheduled Deployments",
4759
- annotations: { readOnlyHint: true },
4725
+ annotations: READ_ONLY,
4760
4726
  description: `List scheduled deployments across all groups (all statuses: ${DeploymentScheduleStatus.join(", ")}), newest first.`,
4761
4727
  inputSchema: z8.object({
4762
4728
  page: z8.number().int().min(0).default(0).describe("Page number"),
@@ -4769,7 +4735,7 @@ function registerDeployTools(server, callApi) {
4769
4735
  "lexq_deploy_history",
4770
4736
  {
4771
4737
  title: "Deployment History",
4772
- annotations: { readOnlyHint: true },
4738
+ annotations: READ_ONLY,
4773
4739
  description: "List deployment history across all groups.",
4774
4740
  inputSchema: z8.object({
4775
4741
  page: z8.number().int().min(0).default(0).describe("Page number"),
@@ -4793,7 +4759,7 @@ function registerDeployTools(server, callApi) {
4793
4759
  "lexq_deploy_detail",
4794
4760
  {
4795
4761
  title: "Deployment Detail",
4796
- annotations: { readOnlyHint: true },
4762
+ annotations: READ_ONLY,
4797
4763
  description: "Get detailed info about a specific deployment including snapshot hash and integrity check.",
4798
4764
  inputSchema: z8.object({
4799
4765
  deploymentId: z8.string().uuid().describe("Deployment ID")
@@ -4805,7 +4771,7 @@ function registerDeployTools(server, callApi) {
4805
4771
  "lexq_deploy_overview",
4806
4772
  {
4807
4773
  title: "Deployment Overview",
4808
- annotations: { readOnlyHint: true },
4774
+ annotations: READ_ONLY,
4809
4775
  description: "Show current deployment status of all groups \u2014 which version is live, last deployment type, and deployer. Each group also carries currentDeploymentId, previousVersionName (the version a rollback restores) and canRollback, as in lexq_groups_get.",
4810
4776
  inputSchema: z8.object({})
4811
4777
  },
@@ -4815,7 +4781,7 @@ function registerDeployTools(server, callApi) {
4815
4781
  "lexq_deploy_deployable",
4816
4782
  {
4817
4783
  title: "List Deployable Versions",
4818
- annotations: { readOnlyHint: true },
4784
+ annotations: READ_ONLY,
4819
4785
  description: "List ACTIVE (published) versions that can be deployed for a group. Use this to find which versions are available before calling deploy live.",
4820
4786
  inputSchema: z8.object({
4821
4787
  groupId: z8.string().uuid().describe("Policy group ID")
@@ -4827,7 +4793,7 @@ function registerDeployTools(server, callApi) {
4827
4793
  "lexq_deploy_diff",
4828
4794
  {
4829
4795
  title: "Deployment Diff",
4830
- annotations: { readOnlyHint: true },
4796
+ annotations: READ_ONLY,
4831
4797
  description: "Compare rule snapshots between two versions. Shows added, removed, and modified rules. Useful for reviewing changes before deploying a new version.",
4832
4798
  inputSchema: z8.object({
4833
4799
  baseVersionId: z8.string().uuid().describe("Base version ID (typically the current live)"),
@@ -4856,7 +4822,7 @@ function registerProfileTools(server, callApi) {
4856
4822
  "lexq_profile_overview",
4857
4823
  {
4858
4824
  title: "Group Latency Profile",
4859
- annotations: { readOnlyHint: true },
4825
+ annotations: READ_ONLY,
4860
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.`,
4861
4827
  inputSchema: z9.object({
4862
4828
  groupId: z9.string().uuid().describe("Policy group ID"),
@@ -4876,7 +4842,7 @@ function registerProfileTools(server, callApi) {
4876
4842
  "lexq_profile_rule",
4877
4843
  {
4878
4844
  title: "Rule Latency Detail",
4879
- annotations: { readOnlyHint: true },
4845
+ annotations: READ_ONLY,
4880
4846
  description: `Single-rule latency detail: merged phase \xD7 cacheState distributions plus a per-window time series (${LATENCY_WINDOW_MILLIS / 1e3}s windows). Missing windows are genuine gaps \u2014 never interpolated. Series points carry each window's own values; percentiles in merged distributions are withheld (null) unless n\xD7(1\u2212q) \u2265 ${TAIL_MIN_OBS} (p50 n \u2265 ${PERCENTILE_MIN_N.p50}, p95 n \u2265 ${PERCENTILE_MIN_N.p95}, p99 n \u2265 ${PERCENTILE_MIN_N.p99}). ` + RELATIVE_THRESHOLD,
4881
4847
  inputSchema: z9.object({
4882
4848
  groupId: z9.string().uuid().describe("Policy group ID"),
@@ -4901,8 +4867,8 @@ function registerReplayTools(server, callApi) {
4901
4867
  "lexq_replay_decision",
4902
4868
  {
4903
4869
  title: "Replay a Decision",
4904
- annotations: { readOnlyHint: true },
4905
- 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.",
4870
+ annotations: READ_ONLY,
4871
+ 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.`,
4906
4872
  inputSchema: z10.object({
4907
4873
  traceId: z10.string().describe("Trace ID of the past execution to replay"),
4908
4874
  candidateVersionId: z10.string().uuid().describe("Version to re-evaluate against")
@@ -4914,7 +4880,7 @@ function registerReplayTools(server, callApi) {
4914
4880
  "lexq_replay_start",
4915
4881
  {
4916
4882
  title: "Start Window Replay (Blast Radius)",
4917
- annotations: { readOnlyHint: false, destructiveHint: false },
4883
+ annotations: DESTRUCTIVE,
4918
4884
  description: "Submit an async job that replays a date window of past executions against a candidate version and measures the blast radius (how many decisions change). Billed per replayed record (REPLAY metric); VIEWER role cannot submit. Poll with lexq_replay_status.",
4919
4885
  inputSchema: z10.object({
4920
4886
  candidateVersionId: z10.string().uuid().describe("Version to re-evaluate against"),
@@ -4931,7 +4897,7 @@ function registerReplayTools(server, callApi) {
4931
4897
  "lexq_replay_status",
4932
4898
  {
4933
4899
  title: "Get Replay Job Status",
4934
- annotations: { readOnlyHint: true },
4900
+ annotations: READ_ONLY,
4935
4901
  description: "Poll a window replay job. RUNNING shows progress 0\u2013100; COMPLETED fills summary and changedSamples; FAILED carries errorMessage. capped=true means the window exceeded the sample cap and only part was replayed.",
4936
4902
  inputSchema: z10.object({
4937
4903
  jobId: z10.string().describe("Replay job ID from lexq_replay_start")
@@ -4943,7 +4909,7 @@ function registerReplayTools(server, callApi) {
4943
4909
  "lexq_replay_list",
4944
4910
  {
4945
4911
  title: "List Replay Jobs",
4946
- annotations: { readOnlyHint: true },
4912
+ annotations: READ_ONLY,
4947
4913
  description: "List window replay job history (reverse-chronological). Lightweight items \u2014 use lexq_replay_status for summary and changed samples.",
4948
4914
  inputSchema: z10.object({
4949
4915
  page: z10.number().int().min(0).default(0).describe("Page number"),
@@ -4956,7 +4922,7 @@ function registerReplayTools(server, callApi) {
4956
4922
  "lexq_replay_cancel",
4957
4923
  {
4958
4924
  title: "Cancel Replay Job",
4959
- annotations: { readOnlyHint: false, destructiveHint: true },
4925
+ annotations: DESTRUCTIVE,
4960
4926
  description: "Cooperatively cancel a PENDING or RUNNING window replay job. Other states are rejected. VIEWER role cannot cancel.",
4961
4927
  inputSchema: z10.object({
4962
4928
  jobId: z10.string().describe("Replay job ID")
@@ -4968,14 +4934,17 @@ function registerReplayTools(server, callApi) {
4968
4934
  "lexq_replay_export",
4969
4935
  {
4970
4936
  title: "Export Replay Result",
4971
- annotations: { readOnlyHint: true },
4972
- 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.",
4937
+ 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}`,
4973
4939
  inputSchema: z10.object({
4974
4940
  jobId: z10.string().describe("Replay job ID"),
4975
4941
  format: z10.enum(EXPORT_FORMATS).default("json").describe("Export format")
4976
4942
  })
4977
4943
  },
4978
- async ({ jobId, format }) => callApi("GET", `replay/jobs/${jobId}/export`, { params: { format } })
4944
+ async ({ jobId, format }) => limitExport(
4945
+ await callApi("GET", `replay/jobs/${jobId}/export`, { params: { format } }),
4946
+ `lexq replay export --id ${jobId} --as ${format} --output <file>`
4947
+ )
4979
4948
  );
4980
4949
  }
4981
4950
 
@@ -4986,7 +4955,7 @@ function registerHistoryTools(server, callApi) {
4986
4955
  "lexq_history_list",
4987
4956
  {
4988
4957
  title: "List Execution History",
4989
- annotations: { readOnlyHint: true },
4958
+ annotations: READ_ONLY,
4990
4959
  description: "List policy execution history. Shows trace ID, group, version, status, match result, and latency.",
4991
4960
  inputSchema: z11.object({
4992
4961
  page: z11.number().int().min(0).default(0).describe("Page number"),
@@ -5014,7 +4983,7 @@ function registerHistoryTools(server, callApi) {
5014
4983
  "lexq_history_get",
5015
4984
  {
5016
4985
  title: "Get Execution Detail",
5017
- annotations: { readOnlyHint: true },
4986
+ annotations: READ_ONLY,
5018
4987
  description: "Get full execution detail including inputFacts, mutatedFacts, generatedVariables, executionTraces, and decisionTraces.",
5019
4988
  inputSchema: z11.object({
5020
4989
  traceId: z11.string().describe("Trace ID from execution history")
@@ -5026,7 +4995,7 @@ function registerHistoryTools(server, callApi) {
5026
4995
  "lexq_history_stats",
5027
4996
  {
5028
4997
  title: "Execution Statistics",
5029
- annotations: { readOnlyHint: true },
4998
+ annotations: READ_ONLY,
5030
4999
  description: `Get execution KPIs: total executions, success/failure counts, success rate, and average latency. Without dates the window is the last ${STATS_DEFAULT_WINDOW_DAYS} days through today, in the organization time zone. At most ${STATS_MAX_WINDOW_DAYS} days per call; a longer range is rejected with AN-039. startDate alone runs through today.`,
5031
5000
  inputSchema: z11.object({
5032
5001
  groupId: z11.string().uuid().optional().describe("Filter by policy group"),
@@ -5051,7 +5020,7 @@ function registerProvenanceTools(server, callApi) {
5051
5020
  "lexq_provenance_get",
5052
5021
  {
5053
5022
  title: "Get Decision Provenance",
5054
- annotations: { readOnlyHint: true },
5023
+ annotations: READ_ONLY,
5055
5024
  description: "Get the lineage of a single decision: what was decided, deterministic why per rule, input facts (PII facts are masked as \u2022\u2022\u2022\u2022\u2022\u2022 with maskedKeys listing them \u2014 values are revealable only in the console, audited), the authored/published/deployed responsibility chain, and the rule snapshot fingerprint.",
5056
5025
  inputSchema: z12.object({
5057
5026
  traceId: z12.string().describe("Trace ID of the execution")
@@ -5063,7 +5032,7 @@ function registerProvenanceTools(server, callApi) {
5063
5032
  "lexq_pii_reveals_list",
5064
5033
  {
5065
5034
  title: "List PII Reveal Audits",
5066
- annotations: { readOnlyHint: true },
5035
+ annotations: READ_ONLY,
5067
5036
  description: "List the PII reveal audit ledger \u2014 who revealed which fact of which trace, and when. Metadata only; revealed values are never stored or returned. Use for monthly access-log inspection and SIEM collection.",
5068
5037
  inputSchema: z12.object({
5069
5038
  page: z12.number().int().min(0).default(0).describe("Page number"),
@@ -5089,17 +5058,13 @@ function registerProvenanceTools(server, callApi) {
5089
5058
 
5090
5059
  // src/mcp/tools/logs.ts
5091
5060
  import { z as z13 } from "zod";
5092
- var ACTION_NOTES = {
5093
- RESOLVE: "mark as manually fixed",
5094
- IGNORE: "skip intentionally"
5095
- };
5096
5061
  function registerLogTools(server, callApi) {
5097
5062
  server.registerTool(
5098
5063
  "lexq_logs_list",
5099
5064
  {
5100
5065
  title: "List Failure Logs",
5101
- annotations: { readOnlyHint: true },
5102
- description: `List system failure logs from background tasks (platform event webhooks, scheduled deployments). Without dates the window is ${FAILURE_LOG_LOOKBACK_DAYS} days back through today, in the organization time zone; pass startDate to look further back.`,
5066
+ annotations: READ_ONLY,
5067
+ description: `List system failure logs from background tasks (platform event webhooks, scheduled deployments). Without dates the window is ${FAILURE_LOG_LOOKBACK_DAYS} days back through today, in the organization time zone; pass startDate to look further back. ${FAILURE_LOG_CONSOLE_NOTE}`,
5103
5068
  inputSchema: z13.object({
5104
5069
  page: z13.number().int().min(0).default(0).describe("Page number"),
5105
5070
  size: z13.number().int().min(1).max(100).default(20).describe("Page size"),
@@ -5124,44 +5089,14 @@ function registerLogTools(server, callApi) {
5124
5089
  "lexq_logs_get",
5125
5090
  {
5126
5091
  title: "Get Failure Log",
5127
- annotations: { readOnlyHint: true },
5128
- description: "Get failure log detail by ID.",
5092
+ annotations: READ_ONLY,
5093
+ description: `Get failure log detail by ID. ${FAILURE_LOG_CONSOLE_NOTE}`,
5129
5094
  inputSchema: z13.object({
5130
5095
  logId: z13.string().uuid().describe("Failure log ID")
5131
5096
  })
5132
5097
  },
5133
5098
  async ({ logId }) => callApi("GET", `failure-logs/${logId}`)
5134
5099
  );
5135
- server.registerTool(
5136
- "lexq_logs_action",
5137
- {
5138
- title: "Process Failure Log",
5139
- annotations: { readOnlyHint: false, destructiveHint: false },
5140
- description: `Process a single failure log: ${withNotes(ACTION_NOTES).join(" or ")}.`,
5141
- inputSchema: z13.object({
5142
- logId: z13.string().uuid().describe("Failure log ID"),
5143
- action: z13.enum(FailureAction).describe("Action to take")
5144
- })
5145
- },
5146
- async ({ logId, action }) => callApi("POST", `failure-logs/${logId}/actions`, {
5147
- params: { action }
5148
- })
5149
- );
5150
- server.registerTool(
5151
- "lexq_logs_bulk_action",
5152
- {
5153
- title: "Bulk Process Failure Logs",
5154
- annotations: { readOnlyHint: false, destructiveHint: false },
5155
- description: "Process multiple failure logs at once. Provide an array of log IDs and the action.",
5156
- inputSchema: z13.object({
5157
- logIds: z13.array(z13.string().uuid()).describe("Array of failure log IDs"),
5158
- action: z13.enum(FailureAction).describe("Action to apply to all logs")
5159
- })
5160
- },
5161
- async ({ logIds, action }) => callApi("POST", "failure-logs/bulk-actions", {
5162
- body: { logIds, action }
5163
- })
5164
- );
5165
5100
  }
5166
5101
 
5167
5102
  // src/mcp/tools/webhook-subscriptions.ts
@@ -5175,7 +5110,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
5175
5110
  "lexq_webhook_subscriptions_list",
5176
5111
  {
5177
5112
  title: "List Webhook Subscriptions",
5178
- annotations: { readOnlyHint: true },
5113
+ annotations: READ_ONLY,
5179
5114
  description: "List platform event webhook subscriptions. These receive deployment lifecycle notifications: publish, deploy, rollback, and undeploy, plus a deployment being scheduled or a schedule being canceled.",
5180
5115
  inputSchema: z14.object({
5181
5116
  page: z14.number().int().min(0).default(0).describe("Page number"),
@@ -5191,7 +5126,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
5191
5126
  "lexq_webhook_subscriptions_get",
5192
5127
  {
5193
5128
  title: "Get Webhook Subscription",
5194
- annotations: { readOnlyHint: true },
5129
+ annotations: READ_ONLY,
5195
5130
  description: "Get webhook subscription detail by ID.",
5196
5131
  inputSchema: z14.object({
5197
5132
  id: z14.string().uuid().describe("Webhook subscription ID")
@@ -5203,7 +5138,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
5203
5138
  "lexq_webhook_subscriptions_save",
5204
5139
  {
5205
5140
  title: "Save Webhook Subscription",
5206
- annotations: { readOnlyHint: false, destructiveHint: true },
5141
+ annotations: { ...DESTRUCTIVE, openWorldHint: true },
5207
5142
  description: `Create or update a webhook subscription. Omit id to create, provide id to update. Events: ${PlatformEventType.join(", ")}. Formats: ${withNotes(FORMAT_NOTES2).join(", ")}.`,
5208
5143
  inputSchema: z14.object({
5209
5144
  id: z14.string().uuid().optional().describe("Subscription ID (omit to create, provide to update)"),
@@ -5221,7 +5156,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
5221
5156
  "lexq_webhook_subscriptions_delete",
5222
5157
  {
5223
5158
  title: "Delete Webhook Subscription",
5224
- annotations: { readOnlyHint: false, destructiveHint: true },
5159
+ annotations: DESTRUCTIVE,
5225
5160
  description: "Delete a webhook subscription by ID.",
5226
5161
  inputSchema: z14.object({
5227
5162
  id: z14.string().uuid().describe("Webhook subscription ID")
@@ -5233,7 +5168,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
5233
5168
  "lexq_webhook_subscriptions_test",
5234
5169
  {
5235
5170
  title: "Test Webhook Subscription",
5236
- annotations: { readOnlyHint: false, destructiveHint: false },
5171
+ annotations: { ...DESTRUCTIVE, openWorldHint: true },
5237
5172
  description: "Send a test event to verify webhook connectivity. Returns the HTTP status code and success/failure message.",
5238
5173
  inputSchema: z14.object({
5239
5174
  id: z14.string().uuid().describe("Webhook subscription ID")
@@ -5250,7 +5185,7 @@ function registerDomainTemplateTools(server, callApi) {
5250
5185
  "lexq_domain_templates_list",
5251
5186
  {
5252
5187
  title: "List Domain Templates",
5253
- annotations: { readOnlyHint: true },
5188
+ annotations: READ_ONLY,
5254
5189
  description: "List all domain templates. A domain template is a starter pack of fact definitions and sample rules for a common decision, such as checkout pricing (ECOMMERCE) or transfer approval (FINTECH). Each entry reports its key, whether it can be applied (isAvailable), and how many facts and sample rules it provisions. Call this before preview or apply to see which templates exist.",
5255
5190
  inputSchema: z15.object({})
5256
5191
  },
@@ -5260,7 +5195,7 @@ function registerDomainTemplateTools(server, callApi) {
5260
5195
  "lexq_domain_templates_preview",
5261
5196
  {
5262
5197
  title: "Preview Domain Template",
5263
- annotations: { readOnlyHint: true },
5198
+ annotations: READ_ONLY,
5264
5199
  description: "Preview exactly what a domain template will provision before applying it: the fact definitions it registers (an input with a value domain carries its allowed values or range), the sample rules it creates with a description of each, and an apply plan. This is a read-only dry run; nothing is created. factConflicts lists keys this tenant already has with a different type, or with different allowed values or a different range on an input fact. While it is not empty, apply fails with P-061 and creates nothing: delete those facts or change them to match the template, then apply. plan is null for a template that cannot be applied yet.",
5265
5200
  inputSchema: z15.object({
5266
5201
  template: z15.string().describe(
@@ -5274,7 +5209,7 @@ function registerDomainTemplateTools(server, callApi) {
5274
5209
  "lexq_domain_templates_apply",
5275
5210
  {
5276
5211
  title: "Apply Domain Template",
5277
- annotations: { readOnlyHint: false, destructiveHint: false },
5212
+ annotations: ADDITIVE,
5278
5213
  description: "Apply a domain template to the current tenant. Creates the template's fact definitions and a new policy group pre-populated with its sample rules as a DRAFT version. Apply never overwrites existing schema: a key that already exists with the same definition is kept as it is. Run lexq_domain_templates_preview first. If its factConflicts is not empty, apply fails with P-061 and creates nothing until those facts are deleted or changed to match the template. A template whose isAvailable is false cannot be applied.",
5279
5214
  inputSchema: z15.object({
5280
5215
  template: z15.string().describe("Domain template key to apply (e.g. ECOMMERCE)."),
@@ -5309,6 +5244,13 @@ function registerAllTools(server, callApi) {
5309
5244
  registerWebhookSubscriptionTools(server, callApi);
5310
5245
  }
5311
5246
  var SERVER_INFO = { name: "lexq", ...server_info_default };
5247
+ var SERVER_INSTRUCTIONS = [
5248
+ "LexQ evaluates business rules: a policy group holds versions, a version holds rules, and rules read input facts.",
5249
+ "- Date filters (yyyy-MM-dd) are whole days in the organization's time zone, not UTC. Call lexq_whoami for tenantTimezone before working out the current date or a date range.",
5250
+ "- Before creating rules, call lexq_facts_list to see which facts exist and their types. When a rule needs a fact that is not defined, confirm its type and description with the user before calling lexq_facts_create.",
5251
+ "- Before lexq_deploy_publish, run lexq_dry_run on the version with representative facts. A published version cannot go back to draft.",
5252
+ `- For anything these tools cannot do, point the user to the LexQ console at ${CONSOLE_URL}.`
5253
+ ].join("\n");
5312
5254
 
5313
5255
  // src/mcp/server.ts
5314
5256
  var __dirname = dirname(fileURLToPath(import.meta.url));
@@ -5325,7 +5267,10 @@ function startMcpServer() {
5325
5267
  const callApi = createCallApiFromConfig();
5326
5268
  serveStdio(
5327
5269
  () => {
5328
- const server = new McpServer({ ...SERVER_INFO, version });
5270
+ const server = new McpServer(
5271
+ { ...SERVER_INFO, version },
5272
+ { instructions: SERVER_INSTRUCTIONS }
5273
+ );
5329
5274
  registerAllTools(server, callApi);
5330
5275
  registerLocalTools(server);
5331
5276
  return server;
@@ -5521,7 +5466,12 @@ function createCli() {
5521
5466
  const program = new Command();
5522
5467
  program.name("lexq").description(
5523
5468
  "Command-line interface for the LexQ Decision Operations Platform. Manage policies, run Impact Simulation, and deploy from your terminal."
5524
- ).version(getVersion2(), "-V, --version").option("--format <format>", "Output format: json or table", "json").option("--api-key <key>", "Override stored API key").option(
5469
+ ).version(getVersion2(), "-V, --version").option(
5470
+ "--format <format>",
5471
+ `How results are printed: ${OUTPUT_FORMATS.join(" or ")}`,
5472
+ parseOutputFormat,
5473
+ "json"
5474
+ ).option("--api-key <key>", "Override stored API key").option(
5525
5475
  "--base-url <url>",
5526
5476
  "Override API base URL \u2014 must include the API prefix, e.g. http://localhost:8080/api/v1/partners"
5527
5477
  ).option("--dry-run", "Preview the HTTP request without executing").option("--verbose", "Show request/response details").option("--no-color", "Disable colored output");