@lexq/cli 2.1.0 → 3.0.0

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
@@ -387,11 +387,56 @@ var MAX_RULES_PER_VERSION = 100;
387
387
  var MAX_CONDITIONS_PER_RULE = 25;
388
388
  var MAX_CONDITION_DEPTH = 10;
389
389
  var MAX_SNAPSHOT_BYTES = 131072;
390
+ var STATS_DEFAULT_WINDOW_DAYS = 30;
391
+ var STATS_MAX_WINDOW_DAYS = 31;
392
+ var FAILURE_LOG_LOOKBACK_DAYS = 7;
393
+ var PURGE_AFTER_DAYS = 30;
394
+ var DESCRIPTION_MAX_LENGTH = 255;
395
+ var COMMIT_MESSAGE_MAX_LENGTH = 500;
396
+ var SCHEDULE_TICK_MAX_MILLIS = 6e4;
390
397
 
391
398
  // src/commands/groups.ts
392
399
  import "commander";
393
400
  import dedent3 from "dedent";
394
401
 
402
+ // src/types/enums.ts
403
+ var PolicyGroupStatus = ["ACTIVE", "DISABLED", "ARCHIVED"];
404
+ var LogicalOperator = ["AND", "OR"];
405
+ var MutateOperator = ["ASSIGN", "ADD", "SUB", "MUL", "DIV"];
406
+ var CalculationMethod = ["PERCENTAGE", "AMOUNT"];
407
+ var RoundingPolicy = [
408
+ "UP",
409
+ "DOWN",
410
+ "CEILING",
411
+ "FLOOR",
412
+ "HALF_UP",
413
+ "HALF_DOWN",
414
+ "HALF_EVEN"
415
+ ];
416
+ var ValueType = ["STRING", "NUMBER", "BOOLEAN", "LIST_STRING", "LIST_NUMBER"];
417
+ var ConflictResolutionMode = ["NONE", "EXCLUSIVE", "MAX_N"];
418
+ var ConflictResolutionStrategy = ["HIGHEST_PRIORITY"];
419
+ var DeploymentType = ["DEPLOY", "ROLLBACK", "UNDEPLOY"];
420
+ var ApiExecutionStatus = ["SUCCESS", "NO_MATCH", "ERROR", "TIMEOUT"];
421
+ var ProfileCacheState = ["HIT", "MISS"];
422
+ var SimulationStatus = ["PENDING", "RUNNING", "COMPLETED", "FAILED", "CANCELED"];
423
+ var SimulationDatasetType = ["HISTORICAL", "UPLOADED", "MANUAL"];
424
+ var SimulationDatasetSource = ["EXECUTION_LOGS", "S3_BUCKET", "REQUEST_BODY"];
425
+ var SimulationMetricType = ["COUNT", "SUM", "AVG"];
426
+ var ExportFormat = ["CSV", "JSON"];
427
+ var FailureStatus = ["PENDING", "RESOLVED", "IGNORED"];
428
+ var TaskType = ["PLATFORM_WEBHOOK", "SCHEDULED_DEPLOYMENT"];
429
+ var PlatformEventType = [
430
+ "VERSION_PUBLISHED",
431
+ "DEPLOYED",
432
+ "ROLLED_BACK",
433
+ "UNDEPLOYED",
434
+ "DEPLOY_SCHEDULED",
435
+ "DEPLOY_SCHEDULE_CANCELED"
436
+ ];
437
+ var WebhookPayloadFormat = ["GENERIC", "SLACK"];
438
+ var DeploymentScheduleStatus = ["PENDING", "EXECUTED", "CANCELED", "FAILED"];
439
+
395
440
  // src/lib/dates.ts
396
441
  var ORG_DAY = "yyyy-MM-dd, a whole calendar day in the organization time zone (tenantTimezone in whoami), not UTC";
397
442
  var START_DAY = `Start date, ${ORG_DAY}. Inclusive.`;
@@ -399,9 +444,6 @@ var END_DAY = `End date, ${ORG_DAY}. Inclusive: the whole day counts.`;
399
444
  var WINDOW_START_DAY = `Window start date, ${ORG_DAY}. Inclusive.`;
400
445
  var WINDOW_END_DAY = `Window end date, ${ORG_DAY}. Inclusive: the whole day counts.`;
401
446
  var INSTANT = "ISO-8601 instant with Z or an offset, e.g. 2026-10-01T00:00:00Z or 2026-10-01T09:00:00+09:00. A bare date or a time without an offset is rejected.";
402
- var STATS_DEFAULT_WINDOW_DAYS = 30;
403
- var STATS_MAX_WINDOW_DAYS = 31;
404
- var FAILURE_LOG_LOOKBACK_DAYS = 7;
405
447
  function utcMinute(instant) {
406
448
  return instant ? `${new Date(instant).toISOString().substring(0, 16)}Z` : "\u2013";
407
449
  }
@@ -424,7 +466,7 @@ function registerGroupCommands(program) {
424
466
  delete Archive a group
425
467
  ab-test Manage A/B tests (start, stop, adjust)
426
468
 
427
- Statuses: ACTIVE, DISABLED, ARCHIVED
469
+ Statuses: ${PolicyGroupStatus.join(", ")}
428
470
  DISABLED stops production execution (403 P-009) and keeps the deployed version and any A/B test.
429
471
  Dry run, simulation, and replay still work. Set ACTIVE to resume.
430
472
  ARCHIVED means deleted. Only 'lexq groups delete' sets it, and it cannot be undone.
@@ -490,9 +532,9 @@ ${data.length} total`);
490
532
 
491
533
  Fields:
492
534
  name string Group name (required, unique per tenant)
493
- description string Description (optional, max 255 chars)
535
+ description string Description (optional, max ${DESCRIPTION_MAX_LENGTH} chars)
494
536
  activationGroup string Execution Group — conflict-resolution cluster key (optional)
495
- activationMode string NONE | EXCLUSIVE | MAX_N [default: NONE]
537
+ activationMode string ${ConflictResolutionMode.join(" | ")} [default: NONE]
496
538
  activationStrategy string HIGHEST_PRIORITY [default, and currently the only value]
497
539
  executionLimit number Max rules to fire in MAX_N mode (optional)
498
540
  status string ACTIVE | DISABLED [default: ACTIVE]
@@ -554,7 +596,7 @@ ${data.length} total`);
554
596
  It cannot be restored, and calls that change it (settings, versions, rules, starting an A/B
555
597
  test, publish, deploy, rollback, scheduling a deployment) return 409 P-059. If it was ever
556
598
  executed, deployed, or replayed, it is kept for audit; otherwise it is permanently deleted
557
- after 30 days.
599
+ after ${PURGE_AFTER_DAYS} days.
558
600
 
559
601
  Use --force to skip the confirmation prompt.
560
602
  `
@@ -826,7 +868,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
826
868
  }'
827
869
 
828
870
  Fields:
829
- commitMessage string Version description (optional, max 255 chars)
871
+ commitMessage string Version description (optional, max ${COMMIT_MESSAGE_MAX_LENGTH} chars)
830
872
  effectiveFrom instant Start of effective period (optional, with Z or an offset)
831
873
  effectiveTo instant End of effective period (optional, auto-expires)
832
874
  `
@@ -1091,11 +1133,11 @@ ${data.length} total`);
1091
1133
  refVar is the percentage base and is optional — omit to use targetVar itself.
1092
1134
  It is only valid in PERCENTAGE × {ASSIGN, ADD, SUB}.
1093
1135
 
1094
- Value Types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER
1136
+ Value Types: ${ValueType.join(", ")}
1095
1137
 
1096
1138
  Mutex (optional — rule-level conflict resolution):
1097
1139
  mutexGroup string Logical grouping key (e.g., "best-discount")
1098
- mutexMode string NONE | EXCLUSIVE | MAX_N [default: NONE]
1140
+ mutexMode string ${ConflictResolutionMode.join(" | ")} [default: NONE]
1099
1141
  mutexStrategy string HIGHEST_PRIORITY (currently the only value)
1100
1142
  mutexLimit number Max rules to fire in MAX_N mode (required when MAX_N)
1101
1143
 
@@ -1304,27 +1346,6 @@ function escapeUnsafe(json) {
1304
1346
 
1305
1347
  // src/lib/export.ts
1306
1348
  import { writeFileSync as writeFileSync2 } from "fs";
1307
-
1308
- // src/types/enums.ts
1309
- var ValueType = ["STRING", "NUMBER", "BOOLEAN", "LIST_STRING", "LIST_NUMBER"];
1310
- var ConflictResolutionMode = ["NONE", "EXCLUSIVE", "MAX_N"];
1311
- var ConflictResolutionStrategy = ["HIGHEST_PRIORITY"];
1312
- var ProfileCacheState = ["HIT", "MISS"];
1313
- var ExportFormat = ["CSV", "JSON"];
1314
- var FailureStatus = ["PENDING", "RESOLVED", "IGNORED"];
1315
- var FailureAction = ["IGNORE", "RESOLVE"];
1316
- var TaskType = ["PLATFORM_WEBHOOK", "SCHEDULED_DEPLOYMENT"];
1317
- var PlatformEventType = [
1318
- "VERSION_PUBLISHED",
1319
- "DEPLOYED",
1320
- "ROLLED_BACK",
1321
- "UNDEPLOYED",
1322
- "DEPLOY_SCHEDULED",
1323
- "DEPLOY_SCHEDULE_CANCELED"
1324
- ];
1325
- var WebhookPayloadFormat = ["GENERIC", "SLACK"];
1326
-
1327
- // src/lib/export.ts
1328
1349
  var EXPORT_FORMATS = ExportFormat.map((name) => name.toLowerCase()).sort();
1329
1350
  function parseFormat(value) {
1330
1351
  if (typeof value === "string" && EXPORT_FORMATS.includes(value)) {
@@ -1459,7 +1480,7 @@ ${data.length} unregistered`);
1459
1480
  process.exit(1);
1460
1481
  }
1461
1482
  });
1462
- facts.command("create").description("Create a new fact definition").option("--key <key>", "Fact key (letters, numbers, underscores)").option("--name <n>", "Display name").option("--type <type>", "Value type: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER").option("--description <desc>", "Description").option("--required", "Mark as required", false).option("--pii", "Mark as PII \u2014 value is masked on every read surface", false).option("--allowed-values <csv>", "Comma-separated list of accepted values").option("--min <number>", "Lower bound, inclusive (numeric types only)").option("--max <number>", "Upper bound, inclusive (numeric types only)").option("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
1483
+ facts.command("create").description("Create a new fact definition").option("--key <key>", "Fact key (letters, numbers, underscores)").option("--name <n>", "Display name").option("--type <type>", `Value type: ${ValueType.join(", ")}`).option("--description <desc>", "Description").option("--required", "Mark as required", false).option("--pii", "Mark as PII \u2014 value is masked on every read surface", false).option("--allowed-values <csv>", "Comma-separated list of accepted values").option("--min <number>", "Lower bound, inclusive (numeric types only)").option("--max <number>", "Upper bound, inclusive (numeric types only)").option("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
1463
1484
  "after",
1464
1485
  dedent6`
1465
1486
 
@@ -1479,7 +1500,7 @@ ${data.length} unregistered`);
1479
1500
  $ lexq facts create --key tier --name "Tier" --type STRING \
1480
1501
  --allowed-values "GOLD, SILVER, BRONZE"
1481
1502
 
1482
- Value Types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER
1503
+ Value Types: ${ValueType.join(", ")}
1483
1504
  Key Format: starts with a letter, then letters, numbers, and underscores.
1484
1505
  Casing is yours to choose (e.g., paymentAmount, payment_amount)
1485
1506
 
@@ -1508,7 +1529,7 @@ ${data.length} unregistered`);
1508
1529
  process.exit(1);
1509
1530
  }
1510
1531
  });
1511
- facts.command("update").description("Update a fact definition").requiredOption("--id <factId>", "Fact definition ID").option("--name <n>", "Display name").option("--description <desc>", 'Description (pass "" to remove it)').option("--required", "Mark as required").option("--no-required", "Mark as not required").option("--pii", "Mark as PII (enables masking)").option("--no-pii", "Unmark as PII (disables masking \u2014 value becomes visible)").option("--type <type>", "Value type: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER").option("--allowed-values <csv>", "Comma-separated list of accepted values").option("--min <number>", "Lower bound, inclusive (numeric types only)").option("--max <number>", "Upper bound, inclusive (numeric types only)").option("--clear-value-domain", "Remove the value domain entirely").option("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
1532
+ facts.command("update").description("Update a fact definition").requiredOption("--id <factId>", "Fact definition ID").option("--name <n>", "Display name").option("--description <desc>", 'Description (pass "" to remove it)').option("--required", "Mark as required").option("--no-required", "Mark as not required").option("--pii", "Mark as PII (enables masking)").option("--no-pii", "Unmark as PII (disables masking \u2014 value becomes visible)").option("--type <type>", `Value type: ${ValueType.join(", ")}`).option("--allowed-values <csv>", "Comma-separated list of accepted values").option("--min <number>", "Lower bound, inclusive (numeric types only)").option("--max <number>", "Upper bound, inclusive (numeric types only)").option("--clear-value-domain", "Remove the value domain entirely").option("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
1512
1533
  "after",
1513
1534
  dedent6`
1514
1535
 
@@ -1892,7 +1913,7 @@ function registerDeployCommands(program) {
1892
1913
  process.exit(1);
1893
1914
  }
1894
1915
  });
1895
- deploy.command("history").description("List deployment history").option("--group-id <groupId>", "Filter by policy group").option("--types <types>", "Filter by types (comma-separated: DEPLOY,ROLLBACK,UNDEPLOY)").option("--start-date <date>", START_DAY).option("--end-date <date>", END_DAY).option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").addHelpText(
1916
+ deploy.command("history").description("List deployment history").option("--group-id <groupId>", "Filter by policy group").option("--types <types>", `Filter by types (comma-separated: ${DeploymentType.join(",")})`).option("--start-date <date>", START_DAY).option("--end-date <date>", END_DAY).option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").addHelpText(
1896
1917
  "after",
1897
1918
  dedent7`
1898
1919
 
@@ -2052,7 +2073,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2052
2073
  dedent7`
2053
2074
 
2054
2075
  The version must be ACTIVE with a future effective start date; the system
2055
- deploys it automatically at that time (within one scheduler tick, ≤60s).
2076
+ deploys it automatically at that time (within one scheduler tick, ≤${SCHEDULE_TICK_MAX_MILLIS / 1e3}s).
2056
2077
  That time is the version's effectiveFrom, set on the DRAFT before publishing;
2057
2078
  this command takes no time of its own.
2058
2079
  One pending schedule per group. Manual deploy/rollback/undeploy, starting
@@ -2377,8 +2398,8 @@ function registerAnalyticsCommands(program) {
2377
2398
 
2378
2399
  $ lexq analytics simulation start --file simulation.json
2379
2400
 
2380
- Dataset types: HISTORICAL, UPLOADED, MANUAL
2381
- Dataset sources: EXECUTION_LOGS, S3_BUCKET, REQUEST_BODY
2401
+ Dataset types: ${SimulationDatasetType.join(", ")}
2402
+ Dataset sources: ${SimulationDatasetSource.join(", ")}
2382
2403
  HISTORICAL from / to are yyyy-MM-dd, whole days in the organization time zone, both inclusive.
2383
2404
  `
2384
2405
  ).action(async (opts) => {
@@ -2499,7 +2520,7 @@ function registerAnalyticsCommands(program) {
2499
2520
  process.exit(1);
2500
2521
  }
2501
2522
  });
2502
- sim.command("list").description("List simulation history").option("--status <status>", "Filter by status (PENDING, RUNNING, COMPLETED, FAILED, CANCELED)").option("--from <date>", START_DAY).option("--to <date>", END_DAY).option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").action(async (opts) => {
2523
+ sim.command("list").description("List simulation history").option("--status <status>", `Filter by status (${SimulationStatus.join(", ")})`).option("--from <date>", START_DAY).option("--to <date>", END_DAY).option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").action(async (opts) => {
2503
2524
  try {
2504
2525
  const globalOpts = program.opts();
2505
2526
  const format = globalOpts.format ?? "json";
@@ -2586,17 +2607,13 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2586
2607
  ).action(async (opts) => {
2587
2608
  try {
2588
2609
  const globalOpts = program.opts();
2589
- await runExport(
2590
- `analytics/simulations/${opts.id}/export`,
2591
- parseFormat(opts.as),
2592
- {
2593
- apiKey: globalOpts.apiKey,
2594
- baseUrl: globalOpts.baseUrl,
2595
- dryRun: globalOpts.dryRun,
2596
- verbose: globalOpts.verbose,
2597
- output: opts.output
2598
- }
2599
- );
2610
+ await runExport(`analytics/simulations/${opts.id}/export`, parseFormat(opts.as), {
2611
+ apiKey: globalOpts.apiKey,
2612
+ baseUrl: globalOpts.baseUrl,
2613
+ dryRun: globalOpts.dryRun,
2614
+ verbose: globalOpts.verbose,
2615
+ output: opts.output
2616
+ });
2600
2617
  } catch (error) {
2601
2618
  printError(error);
2602
2619
  process.exit(1);
@@ -2703,6 +2720,15 @@ function resolveBody(opts) {
2703
2720
  // src/commands/profile.ts
2704
2721
  import "commander";
2705
2722
  import dedent9 from "dedent";
2723
+
2724
+ // src/lib/percentiles.ts
2725
+ var PERCENTILE_MIN_N = {
2726
+ p50: TAIL_MIN_OBS * 2,
2727
+ p95: TAIL_MIN_OBS * 20,
2728
+ p99: TAIL_MIN_OBS * 100
2729
+ };
2730
+
2731
+ // src/commands/profile.ts
2706
2732
  var ms = (nanos) => nanos == null ? "\u2013" : (nanos / 1e6).toFixed(2);
2707
2733
  var msWithUnit = (nanos) => nanos == null ? "\u2013" : `${(nanos / 1e6).toFixed(2)}ms`;
2708
2734
  function registerProfileCommands(program) {
@@ -2712,15 +2738,18 @@ function registerProfileCommands(program) {
2712
2738
  ).option("--version-id <versionId>", "Version to inspect (default: live version)").option(
2713
2739
  "--from <instant>",
2714
2740
  `Window start, ISO-8601 instant (default: ${PROFILE_DEFAULT_WINDOW_HOURS}h ago)`
2715
- ).option("--to <instant>", "Window end, ISO-8601 instant (default: now)").option("--cache <state>", "Cache dimension for the rule table: HIT | MISS (default: HIT)").addHelpText(
2741
+ ).option("--to <instant>", "Window end, ISO-8601 instant (default: now)").option(
2742
+ "--cache <state>",
2743
+ `Cache dimension for the rule table: ${ProfileCacheState.join(" | ")} (default: HIT)`
2744
+ ).addHelpText(
2716
2745
  "after",
2717
2746
  dedent9`
2718
2747
 
2719
2748
  Slow-rule judgment is relative only: flagged = p50 ≥ ${SLOW_MULTIPLIER}× the median of
2720
2749
  per-rule p50s within the group. Absolute ms thresholds are intentionally
2721
- not supported. Each percentile is withheld (–) unless n×(1−q) ≥ 3 —
2722
- p50 from n ≥ 6, p95 from n ≥ 60, p99 from n ≥ 300. TOTAL is recorded
2723
- on every call, rule detail from a deterministic 1% sample.
2750
+ not supported. Each percentile is withheld (–) unless n×(1−q) ≥ ${TAIL_MIN_OBS} —
2751
+ p50 from n ≥ ${PERCENTILE_MIN_N.p50}, p95 from n ≥ ${PERCENTILE_MIN_N.p95}, p99 from n ≥ ${PERCENTILE_MIN_N.p99}. TOTAL is recorded
2752
+ on every call, rule detail from a deterministic ${PROFILE_SAMPLE_PERMILLE / 10}% sample.
2724
2753
 
2725
2754
  Examples:
2726
2755
  $ lexq profile <groupId>
@@ -2818,10 +2847,10 @@ function registerHistoryCommands(program) {
2818
2847
  get Get full execution detail (request facts, traces, decisions)
2819
2848
  stats Aggregate statistics (success rate, latency, counts)
2820
2849
 
2821
- Statuses: SUCCESS, NO_MATCH, ERROR, TIMEOUT
2850
+ Statuses: ${ApiExecutionStatus.join(", ")}
2822
2851
  `
2823
2852
  );
2824
- history.command("list").description("List execution history").option("--trace-id <traceId>", "Filter by trace ID").option("--group-id <groupId>", "Filter by policy group").option("--version-id <versionId>", "Filter by version").option("--status <status>", "Filter by status (SUCCESS, NO_MATCH, ERROR, TIMEOUT)").option("--start-date <date>", START_DAY).option("--end-date <date>", END_DAY).option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").addHelpText(
2853
+ history.command("list").description("List execution history").option("--trace-id <traceId>", "Filter by trace ID").option("--group-id <groupId>", "Filter by policy group").option("--version-id <versionId>", "Filter by version").option("--status <status>", `Filter by status (${ApiExecutionStatus.join(", ")})`).option("--start-date <date>", START_DAY).option("--end-date <date>", END_DAY).option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").addHelpText(
2825
2854
  "after",
2826
2855
  dedent10`
2827
2856
 
@@ -3214,6 +3243,22 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
3214
3243
  // src/commands/logs.ts
3215
3244
  import "commander";
3216
3245
  import dedent13 from "dedent";
3246
+
3247
+ // src/lib/enum-notes.ts
3248
+ function withNotes(notes) {
3249
+ return Object.entries(notes).map(([value, note]) => note ? `${value} (${note})` : value);
3250
+ }
3251
+
3252
+ // src/lib/console.ts
3253
+ var CONSOLE_URL = "https://console.lexq.io";
3254
+ 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).`;
3255
+
3256
+ // src/commands/logs.ts
3257
+ var STATUS_NOTES = {
3258
+ PENDING: "needs attention",
3259
+ RESOLVED: "",
3260
+ IGNORED: ""
3261
+ };
3217
3262
  function registerLogCommands(program) {
3218
3263
  const logs = program.command("logs").description("Failure logs").addHelpText(
3219
3264
  "after",
@@ -3224,13 +3269,13 @@ function registerLogCommands(program) {
3224
3269
  Commands:
3225
3270
  list List failure logs with filters
3226
3271
  get Get failure log detail (includes the original payload)
3227
- action Process a single log (IGNORE, RESOLVE)
3228
- bulk-action Process multiple logs at once
3229
3272
 
3230
- Statuses: PENDING (needs attention), RESOLVED, IGNORED
3273
+ ${FAILURE_LOG_CONSOLE_NOTE}
3274
+
3275
+ Statuses: ${withNotes(STATUS_NOTES).join(", ")}
3231
3276
  `
3232
3277
  );
3233
- logs.command("list").description("List failure logs").option("--task-type <taskType>", `Filter by task type (${TaskType.join(", ")})`).option("--status <status>", "Filter by status (PENDING, RESOLVED, IGNORED)").option("--keyword <keyword>", "Search keyword").option("--start-date <date>", START_DAY).option("--end-date <date>", END_DAY).option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").addHelpText(
3278
+ logs.command("list").description("List failure logs").option("--task-type <taskType>", `Filter by task type (${TaskType.join(", ")})`).option("--status <status>", `Filter by status (${FailureStatus.join(", ")})`).option("--keyword <keyword>", "Search keyword").option("--start-date <date>", START_DAY).option("--end-date <date>", END_DAY).option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").addHelpText(
3234
3279
  "after",
3235
3280
  dedent13`
3236
3281
 
@@ -3286,7 +3331,8 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
3286
3331
  dedent13`
3287
3332
 
3288
3333
  Includes the full payload that was used for the failed operation.
3289
- Use this to inspect what went wrong before deciding to IGNORE or RESOLVE.
3334
+ Use this to inspect what went wrong. Resolving or ignoring a log happens in the
3335
+ console; see lexq logs --help.
3290
3336
  `
3291
3337
  ).action(async (opts) => {
3292
3338
  try {
@@ -3303,68 +3349,15 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
3303
3349
  process.exit(1);
3304
3350
  }
3305
3351
  });
3306
- logs.command("action").description("Process a failure log action (IGNORE, RESOLVE)").requiredOption("--id <logId>", "Log ID").requiredOption("--action <action>", "Action: IGNORE or RESOLVE").addHelpText(
3307
- "after",
3308
- dedent13`
3309
-
3310
- Actions:
3311
- IGNORE Mark as intentionally skipped (won't appear in PENDING)
3312
- RESOLVE Mark as manually resolved (e.g., fixed via external system)
3313
-
3314
- Example:
3315
- $ lexq logs action --id <logId> --action RESOLVE
3316
- `
3317
- ).action(async (opts) => {
3318
- try {
3319
- const globalOpts = program.opts();
3320
- const data = await apiRequest(
3321
- "POST",
3322
- `failure-logs/${opts.id}/actions`,
3323
- {
3324
- apiKey: globalOpts.apiKey,
3325
- baseUrl: globalOpts.baseUrl,
3326
- dryRun: globalOpts.dryRun,
3327
- verbose: globalOpts.verbose,
3328
- params: { action: opts.action }
3329
- }
3330
- );
3331
- printJson(data);
3332
- } catch (error) {
3333
- printError(error);
3334
- process.exit(1);
3335
- }
3336
- });
3337
- logs.command("bulk-action").description("Bulk process failure logs").requiredOption("--ids <logIds>", "Comma-separated log IDs").requiredOption("--action <action>", "Action: IGNORE or RESOLVE").addHelpText(
3338
- "after",
3339
- dedent13`
3340
-
3341
- Processes each log individually. Failures are skipped with a warning.
3342
-
3343
- Example:
3344
- $ lexq logs bulk-action --ids "id1,id2,id3" --action RESOLVE
3345
- `
3346
- ).action(async (opts) => {
3347
- try {
3348
- const globalOpts = program.opts();
3349
- const logIds = opts.ids.split(",").map((id) => id.trim());
3350
- const data = await apiRequest("POST", "failure-logs/bulk-actions", {
3351
- apiKey: globalOpts.apiKey,
3352
- baseUrl: globalOpts.baseUrl,
3353
- dryRun: globalOpts.dryRun,
3354
- verbose: globalOpts.verbose,
3355
- body: { logIds, action: opts.action }
3356
- });
3357
- printJson(data);
3358
- } catch (error) {
3359
- printError(error);
3360
- process.exit(1);
3361
- }
3362
- });
3363
3352
  }
3364
3353
 
3365
3354
  // src/commands/webhook-subscriptions.ts
3366
3355
  import "commander";
3367
3356
  import dedent14 from "dedent";
3357
+ var FORMAT_NOTES = {
3358
+ GENERIC: "full JSON",
3359
+ SLACK: '{"text":"..."}'
3360
+ };
3368
3361
  function registerWebhookSubscriptionCommands(program) {
3369
3362
  const webhooks = program.command("webhook-subscriptions").description("Manage platform event webhook subscriptions").addHelpText(
3370
3363
  "after",
@@ -3467,7 +3460,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
3467
3460
  name string Subscription name (required, unique per tenant)
3468
3461
  webhookUrl string Webhook endpoint URL (required)
3469
3462
  subscribedEvents string[] ${PlatformEventType.join(", ")}
3470
- payloadFormat string GENERIC (full JSON) or SLACK ({"text":"..."}) [default: GENERIC]
3463
+ payloadFormat string ${withNotes(FORMAT_NOTES).join(" or ")} [default: GENERIC]
3471
3464
  secret string HMAC-SHA256 signing secret (optional)
3472
3465
  isActive boolean Enable/disable (optional) [default: true]
3473
3466
  id string Provide to update, omit to create
@@ -3577,6 +3570,21 @@ import { serveStdio } from "@modelcontextprotocol/server/stdio";
3577
3570
  import { McpServer } from "@modelcontextprotocol/server";
3578
3571
 
3579
3572
  // src/mcp/tools/_shared.ts
3573
+ var READ_ONLY = {
3574
+ readOnlyHint: true,
3575
+ destructiveHint: false,
3576
+ openWorldHint: false
3577
+ };
3578
+ var ADDITIVE = {
3579
+ readOnlyHint: false,
3580
+ destructiveHint: false,
3581
+ openWorldHint: false
3582
+ };
3583
+ var DESTRUCTIVE = {
3584
+ readOnlyHint: false,
3585
+ destructiveHint: true,
3586
+ openWorldHint: false
3587
+ };
3580
3588
  function localApiOptions() {
3581
3589
  const stored = loadConfig();
3582
3590
  return {
@@ -3599,9 +3607,8 @@ function createCallApiFromConfig() {
3599
3607
  form,
3600
3608
  params: opts?.params
3601
3609
  });
3602
- const content = [
3603
- { type: "text", text: stringifyJson(data, 2) ?? "" }
3604
- ];
3610
+ const text = typeof data === "string" ? data : stringifyJson(data, 2) ?? "";
3611
+ const content = [{ type: "text", text }];
3605
3612
  const warning = formatUnregisteredFactWarning(meta);
3606
3613
  if (warning) content.push({ type: "text", text: warning });
3607
3614
  const domainWarning = formatValueDomainMismatchWarning(meta);
@@ -3622,6 +3629,20 @@ function paginationParams(page, size) {
3622
3629
  if (size !== void 0) params.size = String(size);
3623
3630
  return params;
3624
3631
  }
3632
+ var MAX_EXPORT_CHARS = 1e5;
3633
+ var formatCount = (count) => count.toLocaleString("en-US");
3634
+ var EXPORT_LIMIT_NOTE = `An export longer than ${formatCount(MAX_EXPORT_CHARS)} characters is refused.`;
3635
+ function limitExport(result, command, narrower) {
3636
+ if (result.isError) return result;
3637
+ const length = result.content.reduce((sum, block) => sum + block.text.length, 0);
3638
+ if (length <= MAX_EXPORT_CHARS) return result;
3639
+ const text = [
3640
+ `The export is ${formatCount(length)} characters long, and this tool returns at most ${formatCount(MAX_EXPORT_CHARS)}.`,
3641
+ narrower,
3642
+ `To get the whole file, download it from the LexQ console (${CONSOLE_URL}) or with \`${command}\`.`
3643
+ ].filter(Boolean).join(" ");
3644
+ return { content: [{ type: "text", text }], isError: true };
3645
+ }
3625
3646
  function formatUnregisteredFactWarning(meta) {
3626
3647
  const facts = meta?.unregisteredFacts ?? [];
3627
3648
  if (facts.length === 0) return null;
@@ -3633,9 +3654,9 @@ function formatUnregisteredFactWarning(meta) {
3633
3654
  return ` \u2022 ${f.key} (ambiguous) \u2014 pick a type (${choices}), then lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: <chosen> })`;
3634
3655
  });
3635
3656
  return [
3636
- `\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.`,
3657
+ `\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.`,
3637
3658
  ...lines,
3638
- `Register them now so the rule validates as intended, or call lexq_facts_unregistered to review the version's full list.`
3659
+ `Register them before the version is published to have those values checked, or call lexq_facts_unregistered to review the version's full list.`
3639
3660
  ].join("\n");
3640
3661
  }
3641
3662
 
@@ -3661,7 +3682,7 @@ function registerAnalyticsTools(server, callApi) {
3661
3682
  "lexq_dry_run",
3662
3683
  {
3663
3684
  title: "Dry Run",
3664
- annotations: { readOnlyHint: true },
3685
+ annotations: READ_ONLY,
3665
3686
  description: dedent15`
3666
3687
  Execute a single dry run against a version. Tests how rules evaluate given input facts without side effects.
3667
3688
 
@@ -3672,8 +3693,7 @@ function registerAnalyticsTools(server, callApi) {
3672
3693
  executionTraces — per-rule match status
3673
3694
  decisionTraces — per-rule decision (SELECTED / NO_MATCH / BLOCKED / etc.)
3674
3695
 
3675
- Example input: { "facts": { "paymentAmount": 100000, "customerTier": "VIP" } }
3676
- Always dry-run before publishing to validate rule behavior.`,
3696
+ Example input: { "facts": { "paymentAmount": 100000, "customerTier": "VIP" } }`,
3677
3697
  inputSchema: z.object({
3678
3698
  versionId: z.string().uuid().describe("Policy version ID to test against"),
3679
3699
  facts: z.string().describe('JSON string of facts object, e.g. {"paymentAmount":100000}'),
@@ -3691,7 +3711,7 @@ function registerAnalyticsTools(server, callApi) {
3691
3711
  "lexq_dry_run_compare",
3692
3712
  {
3693
3713
  title: "Dry Run Compare",
3694
- annotations: { readOnlyHint: true },
3714
+ annotations: READ_ONLY,
3695
3715
  description: dedent15`
3696
3716
  Compare dry run results between two versions using the same input facts. Useful for validating changes.
3697
3717
 
@@ -3716,7 +3736,7 @@ function registerAnalyticsTools(server, callApi) {
3716
3736
  "lexq_requirements",
3717
3737
  {
3718
3738
  title: "Analyze Requirements",
3719
- annotations: { readOnlyHint: true },
3739
+ annotations: READ_ONLY,
3720
3740
  description: "Analyze which input facts a version requires. Returns required keys, types, and an example request body.",
3721
3741
  inputSchema: z.object({
3722
3742
  groupId: z.string().uuid().describe("Policy group ID"),
@@ -3729,10 +3749,13 @@ function registerAnalyticsTools(server, callApi) {
3729
3749
  "lexq_simulation_start",
3730
3750
  {
3731
3751
  title: "Start Simulation",
3732
- annotations: { readOnlyHint: false, destructiveHint: false },
3752
+ annotations: DESTRUCTIVE,
3733
3753
  description: dedent15`
3734
3754
  Start an Impact Simulation against historical, uploaded, or inline data.
3735
3755
 
3756
+ Starting checks the plan's simulation usage limit. The records a simulation processes
3757
+ count toward that usage when it completes or is canceled.
3758
+
3736
3759
  dataset.type and dataset.source are BOTH required, and must be paired:
3737
3760
  HISTORICAL → source EXECUTION_LOGS, with dataset.from / dataset.to (yyyy-MM-dd, whole days
3738
3761
  in the organization time zone, both inclusive)
@@ -3744,7 +3767,7 @@ function registerAnalyticsTools(server, callApi) {
3744
3767
  options.baselinePolicyVersionId: uuid (optional, for baseline comparison)
3745
3768
  options.includeRuleStats: boolean
3746
3769
  options.metricConfig: optional — omit for plain execution count. To aggregate a fact, pass
3747
- { "targetVariable": "<fact>", "aggregationType": "COUNT" | "SUM" | "AVG" }
3770
+ { "targetVariable": "<fact>", "aggregationType": ${SimulationMetricType.map((t) => `"${t}"`).join(" | ")} }
3748
3771
 
3749
3772
  Example (uploaded dataset):
3750
3773
  {
@@ -3773,7 +3796,7 @@ function registerAnalyticsTools(server, callApi) {
3773
3796
  "lexq_simulation_status",
3774
3797
  {
3775
3798
  title: "Simulation Status",
3776
- annotations: { readOnlyHint: true },
3799
+ annotations: READ_ONLY,
3777
3800
  description: "Get simulation status and results. Poll until status is COMPLETED or FAILED.",
3778
3801
  inputSchema: z.object({
3779
3802
  simulationId: z.string().uuid().describe("Simulation ID")
@@ -3785,12 +3808,12 @@ function registerAnalyticsTools(server, callApi) {
3785
3808
  "lexq_simulation_list",
3786
3809
  {
3787
3810
  title: "List Simulations",
3788
- annotations: { readOnlyHint: true },
3811
+ annotations: READ_ONLY,
3789
3812
  description: "List simulation history with optional filters.",
3790
3813
  inputSchema: z.object({
3791
3814
  page: z.number().int().min(0).default(0).describe("Page number"),
3792
3815
  size: z.number().int().min(1).max(100).default(20).describe("Page size"),
3793
- status: z.enum(["PENDING", "RUNNING", "COMPLETED", "FAILED", "CANCELED"]).optional().describe("Filter by status"),
3816
+ status: z.enum(SimulationStatus).optional().describe("Filter by status"),
3794
3817
  from: z.string().optional().describe(START_DAY),
3795
3818
  to: z.string().optional().describe(END_DAY)
3796
3819
  })
@@ -3807,7 +3830,7 @@ function registerAnalyticsTools(server, callApi) {
3807
3830
  "lexq_simulation_cancel",
3808
3831
  {
3809
3832
  title: "Cancel Simulation",
3810
- annotations: { readOnlyHint: false, destructiveHint: true },
3833
+ annotations: DESTRUCTIVE,
3811
3834
  description: "Cancel a running or pending simulation.",
3812
3835
  inputSchema: z.object({
3813
3836
  simulationId: z.string().uuid().describe("Simulation ID")
@@ -3819,22 +3842,25 @@ function registerAnalyticsTools(server, callApi) {
3819
3842
  "lexq_simulation_export",
3820
3843
  {
3821
3844
  title: "Export Simulation",
3822
- annotations: { readOnlyHint: true },
3823
- description: "Export simulation results as JSON or CSV. Returns the raw data.",
3845
+ annotations: READ_ONLY,
3846
+ description: `Export simulation results as JSON or CSV. Returns the raw data. ${EXPORT_LIMIT_NOTE}`,
3824
3847
  inputSchema: z.object({
3825
3848
  simulationId: z.string().uuid().describe("Simulation ID"),
3826
3849
  format: z.enum(EXPORT_FORMATS).default("json").describe("Export format")
3827
3850
  })
3828
3851
  },
3829
- async ({ simulationId, format }) => callApi("GET", `analytics/simulations/${simulationId}/export`, {
3830
- params: { format }
3831
- })
3852
+ async ({ simulationId, format }) => limitExport(
3853
+ await callApi("GET", `analytics/simulations/${simulationId}/export`, {
3854
+ params: { format }
3855
+ }),
3856
+ `lexq analytics simulation export --id ${simulationId} --as ${format} --output <file>`
3857
+ )
3832
3858
  );
3833
3859
  server.registerTool(
3834
3860
  "lexq_dataset_upload",
3835
3861
  {
3836
3862
  title: "Upload Dataset",
3837
- annotations: { readOnlyHint: false, destructiveHint: false },
3863
+ annotations: ADDITIVE,
3838
3864
  description: dedent15`
3839
3865
  Upload inline CSV or JSON content as a simulation dataset.
3840
3866
  The content is uploaded to S3 and a path is returned in the "path" field.
@@ -3877,7 +3903,7 @@ function registerAnalyticsTools(server, callApi) {
3877
3903
  "lexq_dataset_template",
3878
3904
  {
3879
3905
  title: "Download Dataset Template",
3880
- annotations: { readOnlyHint: true },
3906
+ annotations: READ_ONLY,
3881
3907
  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.",
3882
3908
  inputSchema: z.object({
3883
3909
  groupId: z.string().uuid().describe("Policy group ID"),
@@ -3934,7 +3960,7 @@ function registerLocalTools(server) {
3934
3960
  "lexq_dataset_upload_file",
3935
3961
  {
3936
3962
  title: "Upload Dataset File",
3937
- annotations: { readOnlyHint: false, destructiveHint: false },
3963
+ annotations: ADDITIVE,
3938
3964
  description: dedent16`
3939
3965
  Upload a CSV or JSON file from this computer as a simulation dataset, up to ${MAX_DATASET_MB} MB.
3940
3966
  The server reads the file from disk, so its content never passes through the conversation.
@@ -3993,7 +4019,7 @@ function registerStatusTools(server, callApi) {
3993
4019
  "lexq_whoami",
3994
4020
  {
3995
4021
  title: "Who Am I",
3996
- annotations: { readOnlyHint: true },
4022
+ annotations: READ_ONLY,
3997
4023
  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.',
3998
4024
  inputSchema: z3.object({})
3999
4025
  },
@@ -4008,7 +4034,7 @@ function registerGroupTools(server, callApi) {
4008
4034
  "lexq_groups_list",
4009
4035
  {
4010
4036
  title: "List Policy Groups",
4011
- annotations: { readOnlyHint: true },
4037
+ annotations: READ_ONLY,
4012
4038
  description: "List policy groups (tenant-wide, priority ASC). Deleted groups are not included.",
4013
4039
  inputSchema: z4.object({})
4014
4040
  },
@@ -4018,7 +4044,7 @@ function registerGroupTools(server, callApi) {
4018
4044
  "lexq_groups_get",
4019
4045
  {
4020
4046
  title: "Get Policy Group",
4021
- annotations: { readOnlyHint: true },
4047
+ annotations: READ_ONLY,
4022
4048
  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.",
4023
4049
  inputSchema: z4.object({
4024
4050
  groupId: z4.string().uuid().describe("Policy group ID")
@@ -4030,7 +4056,7 @@ function registerGroupTools(server, callApi) {
4030
4056
  "lexq_groups_create",
4031
4057
  {
4032
4058
  title: "Create Policy Group",
4033
- annotations: { readOnlyHint: false, destructiveHint: false },
4059
+ annotations: ADDITIVE,
4034
4060
  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.`,
4035
4061
  inputSchema: z4.object({
4036
4062
  name: z4.string().describe("Group name (unique among non-ARCHIVED)"),
@@ -4061,13 +4087,13 @@ function registerGroupTools(server, callApi) {
4061
4087
  "lexq_groups_update",
4062
4088
  {
4063
4089
  title: "Update Policy Group",
4064
- annotations: { readOnlyHint: false, destructiveHint: true },
4090
+ annotations: DESTRUCTIVE,
4065
4091
  description: "Update a policy group. Only provided fields are updated; omitted fields remain unchanged. A deleted group cannot be updated (409 P-059).",
4066
4092
  inputSchema: z4.object({
4067
4093
  groupId: z4.string().uuid().describe("Policy group ID"),
4068
4094
  name: z4.string().optional().describe("New name"),
4069
4095
  description: z4.string().optional().describe("New description"),
4070
- status: z4.enum(["ACTIVE", "DISABLED"]).optional().describe(
4096
+ status: z4.enum(PolicyGroupStatus).extract(["ACTIVE", "DISABLED"]).optional().describe(
4071
4097
  "DISABLED stops production execution of this group: execution calls return 403 (P-009), and a composite call that includes the group is rejected in full. The deployed version and any A/B test are kept, and dry run, Impact Simulation, and Decision Replay keep working, so a fix can be verified before setting ACTIVE again. To go back to the previous version, use lexq_deploy_rollback. To take the version out of production, use lexq_deploy_undeploy."
4072
4098
  ),
4073
4099
  activationGroup: z4.string().optional().describe(
@@ -4086,8 +4112,8 @@ function registerGroupTools(server, callApi) {
4086
4112
  "lexq_groups_delete",
4087
4113
  {
4088
4114
  title: "Delete Policy Group",
4089
- annotations: { readOnlyHint: false, destructiveHint: true },
4090
- 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 30 days.",
4115
+ annotations: DESTRUCTIVE,
4116
+ 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.`,
4091
4117
  inputSchema: z4.object({
4092
4118
  groupId: z4.string().uuid().describe("Policy group ID")
4093
4119
  })
@@ -4098,7 +4124,7 @@ function registerGroupTools(server, callApi) {
4098
4124
  "lexq_groups_reorder",
4099
4125
  {
4100
4126
  title: "Reorder Policy Groups",
4101
- annotations: { readOnlyHint: false, destructiveHint: true },
4127
+ annotations: DESTRUCTIVE,
4102
4128
  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.",
4103
4129
  inputSchema: z4.object({
4104
4130
  groupIds: z4.array(z4.string().uuid()).describe("Group IDs in desired priority order (index 0 = priority 1)")
@@ -4116,12 +4142,14 @@ function registerGroupTools(server, callApi) {
4116
4142
  "lexq_ab_test_start",
4117
4143
  {
4118
4144
  title: "Start A/B Test",
4119
- annotations: { readOnlyHint: false, destructiveHint: false },
4120
- 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%.",
4145
+ annotations: DESTRUCTIVE,
4146
+ 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.",
4121
4147
  inputSchema: z4.object({
4122
4148
  groupId: z4.string().uuid().describe("Policy group ID"),
4123
4149
  testVersionId: z4.string().uuid().describe("Challenger version ID. Must be a published version of this group"),
4124
- trafficRate: z4.number().int().min(1).max(99).describe("Traffic percentage routed to challenger (1-99)")
4150
+ trafficRate: z4.number().int().min(MIN_TRAFFIC_RATE).max(MAX_TRAFFIC_RATE).describe(
4151
+ `Traffic percentage routed to challenger (${MIN_TRAFFIC_RATE}-${MAX_TRAFFIC_RATE})`
4152
+ )
4125
4153
  })
4126
4154
  },
4127
4155
  async ({ groupId, ...body }) => callApi("POST", `policy-groups/${groupId}/ab-test`, { body })
@@ -4130,7 +4158,7 @@ function registerGroupTools(server, callApi) {
4130
4158
  "lexq_ab_test_stop",
4131
4159
  {
4132
4160
  title: "Stop A/B Test",
4133
- annotations: { readOnlyHint: false, destructiveHint: true },
4161
+ annotations: DESTRUCTIVE,
4134
4162
  description: "Stop a running A/B test. All traffic is restored to the control (current) version.",
4135
4163
  inputSchema: z4.object({
4136
4164
  groupId: z4.string().uuid().describe("Policy group ID")
@@ -4142,11 +4170,13 @@ function registerGroupTools(server, callApi) {
4142
4170
  "lexq_ab_test_adjust",
4143
4171
  {
4144
4172
  title: "Adjust A/B Test",
4145
- annotations: { readOnlyHint: false, destructiveHint: true },
4173
+ annotations: DESTRUCTIVE,
4146
4174
  description: "Adjust traffic rate of a running A/B test.",
4147
4175
  inputSchema: z4.object({
4148
4176
  groupId: z4.string().uuid().describe("Policy group ID"),
4149
- trafficRate: z4.number().int().min(1).max(99).describe("New traffic percentage for challenger (1-99)")
4177
+ trafficRate: z4.number().int().min(MIN_TRAFFIC_RATE).max(MAX_TRAFFIC_RATE).describe(
4178
+ `New traffic percentage for challenger (${MIN_TRAFFIC_RATE}-${MAX_TRAFFIC_RATE})`
4179
+ )
4150
4180
  })
4151
4181
  },
4152
4182
  async ({ groupId, ...body }) => callApi("PATCH", `policy-groups/${groupId}/ab-test/traffic-rate`, { body })
@@ -4160,7 +4190,7 @@ function registerVersionTools(server, callApi) {
4160
4190
  "lexq_versions_list",
4161
4191
  {
4162
4192
  title: "List Policy Versions",
4163
- annotations: { readOnlyHint: true },
4193
+ annotations: READ_ONLY,
4164
4194
  description: "List all versions of a policy group.",
4165
4195
  inputSchema: z5.object({
4166
4196
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4176,7 +4206,7 @@ function registerVersionTools(server, callApi) {
4176
4206
  "lexq_versions_get",
4177
4207
  {
4178
4208
  title: "Get Policy Version",
4179
- annotations: { readOnlyHint: true },
4209
+ annotations: READ_ONLY,
4180
4210
  description: "Get a single version by ID, including its rules and fact requirements.",
4181
4211
  inputSchema: z5.object({
4182
4212
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4189,7 +4219,7 @@ function registerVersionTools(server, callApi) {
4189
4219
  "lexq_versions_create",
4190
4220
  {
4191
4221
  title: "Create Policy Version",
4192
- annotations: { readOnlyHint: false, destructiveHint: false },
4222
+ annotations: ADDITIVE,
4193
4223
  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.`,
4194
4224
  inputSchema: z5.object({
4195
4225
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4204,7 +4234,7 @@ function registerVersionTools(server, callApi) {
4204
4234
  "lexq_versions_update",
4205
4235
  {
4206
4236
  title: "Update Policy Version",
4207
- annotations: { readOnlyHint: false, destructiveHint: true },
4237
+ annotations: DESTRUCTIVE,
4208
4238
  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.",
4209
4239
  inputSchema: z5.object({
4210
4240
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4212,7 +4242,9 @@ function registerVersionTools(server, callApi) {
4212
4242
  commitMessage: z5.string().optional().describe("New commit message"),
4213
4243
  effectiveFrom: z5.string().optional().describe(`Effective start, ${INSTANT} Omit it to keep the current start.`),
4214
4244
  effectiveTo: z5.string().optional().describe(`Effective end, ${INSTANT} Omit it to keep the current end.`),
4215
- clearEffectiveFrom: z5.boolean().optional().describe("Set to true to remove the effective start. Do not send effectiveFrom with it."),
4245
+ clearEffectiveFrom: z5.boolean().optional().describe(
4246
+ "Set to true to remove the effective start. Do not send effectiveFrom with it."
4247
+ ),
4216
4248
  clearEffectiveTo: z5.boolean().optional().describe("Set to true to remove the effective end. Do not send effectiveTo with it.")
4217
4249
  })
4218
4250
  },
@@ -4222,7 +4254,7 @@ function registerVersionTools(server, callApi) {
4222
4254
  "lexq_versions_delete",
4223
4255
  {
4224
4256
  title: "Delete Policy Version",
4225
- annotations: { readOnlyHint: false, destructiveHint: true },
4257
+ annotations: DESTRUCTIVE,
4226
4258
  description: "Delete a DRAFT version. Only DRAFT versions can be deleted.",
4227
4259
  inputSchema: z5.object({
4228
4260
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4235,7 +4267,7 @@ function registerVersionTools(server, callApi) {
4235
4267
  "lexq_versions_clone",
4236
4268
  {
4237
4269
  title: "Clone Policy Version",
4238
- annotations: { readOnlyHint: false, destructiveHint: false },
4270
+ annotations: ADDITIVE,
4239
4271
  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.`,
4240
4272
  inputSchema: z5.object({
4241
4273
  groupId: z5.string().uuid().describe("Policy group ID"),
@@ -4253,12 +4285,13 @@ function registerVersionTools(server, callApi) {
4253
4285
  // src/mcp/tools/rules.ts
4254
4286
  import { z as z6 } from "zod";
4255
4287
  import dedent17 from "dedent";
4288
+ var oneOf = (values) => values.map((v) => `"${v}"`).join("|");
4256
4289
  function registerRuleTools(server, callApi) {
4257
4290
  server.registerTool(
4258
4291
  "lexq_rules_list",
4259
4292
  {
4260
4293
  title: "List Rules",
4261
- annotations: { readOnlyHint: true },
4294
+ annotations: READ_ONLY,
4262
4295
  description: "List all rules in a version (priority ASC). Returns summary with conditionSummary and actionSummary.",
4263
4296
  inputSchema: z6.object({
4264
4297
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4271,7 +4304,7 @@ function registerRuleTools(server, callApi) {
4271
4304
  "lexq_rules_get",
4272
4305
  {
4273
4306
  title: "Get Rule Detail",
4274
- annotations: { readOnlyHint: true },
4307
+ annotations: READ_ONLY,
4275
4308
  description: "Get full rule detail including condition tree and action definitions.",
4276
4309
  inputSchema: z6.object({
4277
4310
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4285,87 +4318,39 @@ function registerRuleTools(server, callApi) {
4285
4318
  "lexq_rules_create",
4286
4319
  {
4287
4320
  title: "Create Rule",
4288
- annotations: { readOnlyHint: false, destructiveHint: false },
4321
+ annotations: ADDITIVE,
4289
4322
  description: dedent17`
4290
- 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.
4291
-
4292
- Before creating rules with new fact keys, call lexq_facts_list to check existing facts.
4293
- If a required key is missing, ask the user to confirm the type, isRequired, and description
4294
- before calling lexq_facts_create — registering facts enables type validation, Console UI
4295
- autocomplete, and the dry-run requirements analyzer.
4296
-
4297
- An action's targetVar (SET_FACT, MUTATE_FACT) must be a registered fact. Saving or
4298
- publishing a rule whose targetVar is not registered fails with ACT-032, so register it
4299
- with lexq_facts_create first. Keys a rule only reads (condition fields, refVar) may stay
4300
- unregistered.
4301
-
4302
- After saving, lexq_facts_unregistered lists any keys this version references but has not
4303
- defined (non-blocking, version-wide) — use it to decide what to register.
4304
-
4305
- Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: "AND"|"OR", children: [...] }
4306
- Value types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER
4307
- Limits: a condition tree may hold at most ${MAX_CONDITIONS_PER_RULE} conditions (SINGLE nodes; GROUP nodes do not count)
4308
- and nest at most ${MAX_CONDITION_DEPTH} levels below the root. A version holds at most ${MAX_RULES_PER_VERSION} rules.
4309
-
4310
- Operators are constrained by the LEFT fact's type (from lexq_facts_list). Using one outside
4311
- its type is rejected by the server — check the fact type before choosing an operator.
4312
- - STRING fact: EQUALS, NOT_EQUALS, CONTAINS, IN, NOT_IN
4313
- - NUMBER fact: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
4314
- - BOOLEAN fact: EQUALS, NOT_EQUALS
4315
- - LIST_* fact: HAS_ANY, HAS_ALL, HAS_NONE (only these)
4316
-
4317
- HAS_* query list-typed facts. Value is always an array whose element type matches the fact:
4318
- - HAS_ANY: fact has at least one of the given values
4319
- - HAS_ALL: fact has all of the given values
4320
- - HAS_NONE: fact has none of the given values
4321
- Example: { "type": "SINGLE", "field": "userTags", "operator": "HAS_ANY", "value": ["VIP","GOLD"], "valueType": "LIST_STRING" }
4323
+ Create a rule in a DRAFT version. It goes last in priority order; lexq_rules_reorder changes the order.
4322
4324
 
4323
- Do NOT use CONTAINS on a list fact — CONTAINS is substring match on STRING facts only.
4324
- IN is the mirror of HAS_*: IN takes a scalar fact with a list value; HAS_* takes lists on both sides.
4325
+ An action's targetVar must be a registered fact (ACT-032 otherwise). Keys a rule only reads (condition fields, refVar) may stay unregistered.
4325
4326
 
4326
- Actions: [{ type, parameters }]
4327
+ Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: ${oneOf(LogicalOperator)}, children: [...] }
4328
+ Value types: ${ValueType.join(", ")}
4329
+ 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.
4327
4330
 
4328
- Action parameter schemas:
4329
- - MUTATE_FACT: { targetVar: string, operator: "ASSIGN"|"ADD"|"SUB"|"MUL"|"DIV", method: "PERCENTAGE"|"AMOUNT", operand: number, refVar?: string, rounding?: RoundingOption }
4330
- targetVar is the fact this action reads and writes. It must be a registered fact
4331
- (ACT-032 otherwise), and the request must send it (P-015 otherwise). Its value must be
4332
- a number when the action runs.
4333
- A missing required fact throws (no 0 default).
4334
- operand is the arithmetic operand; the unit is dictated by method (percent when
4335
- PERCENTAGE, absolute amount when AMOUNT). Ranges are not constrained — negative values
4336
- and >100 percentages are valid (refunds, surcharges).
4337
- refVar is the base for percentage calculation and is OPTIONAL — omit it to use targetVar
4338
- itself. It is only meaningful in PERCENTAGE × {ASSIGN, ADD, SUB}; specifying it in any
4339
- other cell is an error. Use it when the base differs from the target, e.g.
4340
- "points += orderTotal × 5%" → { targetVar: "points", refVar: "orderTotal",
4341
- operator: "ADD", method: "PERCENTAGE", operand: 5 }.
4342
- operator × method matrix:
4343
- ASSIGN targetVar = operand | targetVar = refVar × operand/100
4344
- ADD targetVar += operand | targetVar += refVar × operand/100
4345
- SUB targetVar -= operand | targetVar -= refVar × operand/100
4346
- MUL targetVar *= operand | targetVar *= (operand/100 + 1)
4347
- DIV targetVar /= operand | invalid
4348
- Constraints: DIV + PERCENTAGE is invalid (use MUL with the inverse). DIV + AMOUNT
4349
- requires operand !== 0.
4350
- - SET_FACT: { targetVar: string, value: string|number|boolean } targetVar must be a
4351
- registered fact (ACT-032 otherwise). The request does not have to send it: SET_FACT is
4352
- the only action whose targetVar may be absent from the input facts. MUTATE_FACT requires
4353
- its target to be present in the input facts.
4354
- - BLOCK: { reason?: string } Records a rejection decision. It does NOT halt rule execution —
4355
- subsequent actions and subsequent winning rules still run. Enforcement is the caller's
4356
- responsibility; the decision surfaces as isBlocked (true) and blockReason (reason, or
4357
- "Policy Violation" when omitted).
4358
- Only BLOCK writes those two keys: saving or publishing a rule whose SET_FACT or
4359
- MUTATE_FACT targets either one fails with ACT-033.
4360
-
4361
- Evaluation order: every rule's condition is checked against the input facts before any
4362
- action runs, so a condition never sees a value an action writes in the same request. Then
4363
- the actions of the winning rules run in priority order. Each action works on the values
4364
- the actions before it left, in its own rule or an earlier one. To test a value an action
4365
- writes, send that value as an input fact in a second request. If a rule should run
4366
- whenever another one does, put its actions in that rule instead.
4367
-
4368
- RoundingOption (optional, MUTATE_FACT only): { scale: integer (0..${MAX_ROUNDING_SCALE}), mode?: "HALF_UP"|"HALF_DOWN"|"HALF_EVEN"|"FLOOR"|"CEILING"|"DOWN"|"UP" } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
4331
+ Operators that work on each fact type:
4332
+ - STRING: EQUALS, NOT_EQUALS, CONTAINS (substring), IN, NOT_IN
4333
+ - NUMBER: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
4334
+ - BOOLEAN: EQUALS, NOT_EQUALS
4335
+ - LIST_STRING, LIST_NUMBER: HAS_ANY, HAS_ALL, HAS_NONE
4336
+ 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.
4337
+ Example: { "type": "SINGLE", "field": "userTags", "operator": "HAS_ANY", "value": ["VIP","GOLD"], "valueType": "LIST_STRING" }
4338
+
4339
+ Actions: [{ type, parameters }]
4340
+ - MUTATE_FACT: { targetVar, operator: ${oneOf(MutateOperator)}, method: ${oneOf(CalculationMethod)}, operand: number, refVar?, rounding? }
4341
+ 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.
4342
+ AMOUNT PERCENTAGE (base = refVar, or targetVar when omitted)
4343
+ ASSIGN targetVar = operand targetVar = base × operand/100
4344
+ ADD targetVar += operand targetVar += base × operand/100
4345
+ SUB targetVar -= operand targetVar -= base × operand/100
4346
+ MUL targetVar *= operand targetVar *= (operand/100 + 1)
4347
+ DIV targetVar /= operand invalid
4348
+ refVar is allowed only with PERCENTAGE and ASSIGN, ADD or SUB. DIV with AMOUNT needs a nonzero operand.
4349
+ rounding: { scale: integer (0..${MAX_ROUNDING_SCALE}), mode?: ${oneOf(RoundingPolicy)} }. mode defaults to HALF_UP. Without rounding, the result keeps full precision.
4350
+ - SET_FACT: { targetVar, value: string|number|boolean }. The request does not have to send targetVar.
4351
+ - 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).
4352
+
4353
+ 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.
4369
4354
  `,
4370
4355
  inputSchema: z6.object({
4371
4356
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4384,7 +4369,7 @@ function registerRuleTools(server, callApi) {
4384
4369
  "lexq_rules_update",
4385
4370
  {
4386
4371
  title: "Update Rule",
4387
- annotations: { readOnlyHint: false, destructiveHint: true },
4372
+ annotations: DESTRUCTIVE,
4388
4373
  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.`,
4389
4374
  inputSchema: z6.object({
4390
4375
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4406,28 +4391,21 @@ function registerRuleTools(server, callApi) {
4406
4391
  "lexq_rules_delete",
4407
4392
  {
4408
4393
  title: "Delete Rule",
4409
- annotations: { readOnlyHint: false, destructiveHint: true },
4394
+ annotations: DESTRUCTIVE,
4410
4395
  description: "Delete a rule from a DRAFT version.",
4411
4396
  inputSchema: z6.object({
4412
4397
  groupId: z6.string().uuid().describe("Policy group ID"),
4413
4398
  versionId: z6.string().uuid().describe("Version ID"),
4414
- ruleId: z6.string().uuid().describe("Rule ID"),
4415
- force: z6.boolean().default(false).describe("Skip confirmation (for the last rule in a version)")
4399
+ ruleId: z6.string().uuid().describe("Rule ID")
4416
4400
  })
4417
4401
  },
4418
- async ({ groupId, versionId, ruleId, force }) => {
4419
- const params = {};
4420
- if (force) params.force = "true";
4421
- return callApi("DELETE", `policy-groups/${groupId}/versions/${versionId}/rules/${ruleId}`, {
4422
- params
4423
- });
4424
- }
4402
+ async ({ groupId, versionId, ruleId }) => callApi("DELETE", `policy-groups/${groupId}/versions/${versionId}/rules/${ruleId}`)
4425
4403
  );
4426
4404
  server.registerTool(
4427
4405
  "lexq_rules_reorder",
4428
4406
  {
4429
4407
  title: "Reorder Rules",
4430
- annotations: { readOnlyHint: false, destructiveHint: true },
4408
+ annotations: DESTRUCTIVE,
4431
4409
  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).",
4432
4410
  inputSchema: z6.object({
4433
4411
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4449,7 +4427,7 @@ function registerRuleTools(server, callApi) {
4449
4427
  "lexq_rules_toggle",
4450
4428
  {
4451
4429
  title: "Toggle Rule",
4452
- annotations: { readOnlyHint: false, destructiveHint: true },
4430
+ annotations: DESTRUCTIVE,
4453
4431
  description: "Enable or disable a rule without deleting it.",
4454
4432
  inputSchema: z6.object({
4455
4433
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -4472,13 +4450,14 @@ var FACT_KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
4472
4450
 
4473
4451
  // src/mcp/tools/facts.ts
4474
4452
  var VALUE_TYPES = ValueType;
4453
+ var IS_REQUIRED_NOTE = "isRequired is stored on the definition only; the engine does not read it.";
4475
4454
  function registerFactTools(server, callApi) {
4476
4455
  server.registerTool(
4477
4456
  "lexq_facts_list",
4478
4457
  {
4479
4458
  title: "List Fact Definitions",
4480
- annotations: { readOnlyHint: true },
4481
- description: "List all fact definitions (input variable schema). Shows key, type, required, and PII status. Always check this before creating rules.",
4459
+ annotations: READ_ONLY,
4460
+ description: `List all fact definitions (input variable schema): key, name, type, description, PII and system flags, and value domain. ${IS_REQUIRED_NOTE}`,
4482
4461
  inputSchema: z7.object({
4483
4462
  page: z7.number().int().min(0).default(0).describe("Page number"),
4484
4463
  size: z7.number().int().min(1).max(100).default(20).describe("Page size"),
@@ -4495,14 +4474,14 @@ function registerFactTools(server, callApi) {
4495
4474
  "lexq_facts_create",
4496
4475
  {
4497
4476
  title: "Create Fact Definition",
4498
- annotations: { readOnlyHint: false, destructiveHint: false },
4499
- description: "Register a new input variable. Key starts with a letter, then letters, numbers, and underscores (e.g. paymentAmount). Casing is not enforced. Types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER.",
4477
+ annotations: ADDITIVE,
4478
+ 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(", ")}.`,
4500
4479
  inputSchema: z7.object({
4501
4480
  key: z7.string().regex(FACT_KEY_PATTERN).describe("Variable key. Any casing; must start with a letter."),
4502
4481
  name: z7.string().describe("Display name"),
4503
4482
  type: z7.enum(VALUE_TYPES).describe("Value type"),
4504
4483
  description: z7.string().optional().describe("Description"),
4505
- isRequired: z7.boolean().default(false).describe("Whether this fact is required for rule evaluation"),
4484
+ isRequired: z7.boolean().default(false).describe(IS_REQUIRED_NOTE),
4506
4485
  isPii: z7.boolean().default(false).describe(
4507
4486
  "Mark as PII \u2014 masked on every read surface, revealable only in the console (audited)"
4508
4487
  ),
@@ -4523,7 +4502,7 @@ function registerFactTools(server, callApi) {
4523
4502
  "lexq_facts_update",
4524
4503
  {
4525
4504
  title: "Update Fact Definition",
4526
- annotations: { readOnlyHint: false, destructiveHint: true },
4505
+ annotations: DESTRUCTIVE,
4527
4506
  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).",
4528
4507
  inputSchema: z7.object({
4529
4508
  factId: z7.string().uuid().describe("Fact definition ID"),
@@ -4532,7 +4511,7 @@ function registerFactTools(server, callApi) {
4532
4511
  type: z7.enum(VALUE_TYPES).optional().describe(
4533
4512
  "Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule outside a deleted group references the fact."
4534
4513
  ),
4535
- isRequired: z7.boolean().optional().describe("Required flag"),
4514
+ isRequired: z7.boolean().optional().describe(IS_REQUIRED_NOTE),
4536
4515
  isPii: z7.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
4537
4516
  valueDomain: z7.object({
4538
4517
  allowedValues: z7.array(z7.union([z7.string(), z7.number()])).optional().describe(
@@ -4551,7 +4530,7 @@ function registerFactTools(server, callApi) {
4551
4530
  "lexq_facts_delete",
4552
4531
  {
4553
4532
  title: "Delete Fact Definition",
4554
- annotations: { readOnlyHint: false, destructiveHint: true },
4533
+ annotations: DESTRUCTIVE,
4555
4534
  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.",
4556
4535
  inputSchema: z7.object({
4557
4536
  factId: z7.string().uuid().describe("Fact definition ID")
@@ -4563,7 +4542,7 @@ function registerFactTools(server, callApi) {
4563
4542
  "lexq_facts_action_metadata",
4564
4543
  {
4565
4544
  title: "Get Action Runtime Fact Metadata",
4566
- annotations: { readOnlyHint: true },
4545
+ annotations: READ_ONLY,
4567
4546
  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.",
4568
4547
  inputSchema: z7.object({})
4569
4548
  },
@@ -4573,8 +4552,8 @@ function registerFactTools(server, callApi) {
4573
4552
  "lexq_facts_unregistered",
4574
4553
  {
4575
4554
  title: "List Unregistered Facts",
4576
- annotations: { readOnlyHint: true },
4577
- 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.",
4555
+ annotations: READ_ONLY,
4556
+ 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).",
4578
4557
  inputSchema: z7.object({
4579
4558
  groupId: z7.string().uuid().describe("Policy group ID"),
4580
4559
  versionId: z7.string().uuid().describe("Version ID")
@@ -4586,16 +4565,20 @@ function registerFactTools(server, callApi) {
4586
4565
  "lexq_facts_export",
4587
4566
  {
4588
4567
  title: "Export Fact Catalog",
4589
- annotations: { readOnlyHint: true },
4590
- 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.",
4568
+ annotations: READ_ONLY,
4569
+ 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}`,
4591
4570
  inputSchema: z7.object({
4592
4571
  format: z7.enum(EXPORT_FORMATS).default("csv").describe("Export format"),
4593
4572
  keyword: z7.string().optional().describe("Filter by key or name")
4594
4573
  })
4595
4574
  },
4596
- async ({ format, keyword }) => callApi("GET", "schema/facts/export", {
4597
- params: keyword ? { format, keyword } : { format }
4598
- })
4575
+ async ({ format, keyword }) => limitExport(
4576
+ await callApi("GET", "schema/facts/export", {
4577
+ params: keyword ? { format, keyword } : { format }
4578
+ }),
4579
+ `lexq facts export --as ${format} --output <file>`,
4580
+ "Pass keyword to export fewer facts."
4581
+ )
4599
4582
  );
4600
4583
  }
4601
4584
 
@@ -4607,7 +4590,7 @@ function registerDeployTools(server, callApi) {
4607
4590
  "lexq_deploy_publish",
4608
4591
  {
4609
4592
  title: "Publish Version",
4610
- annotations: { readOnlyHint: false, destructiveHint: false },
4593
+ annotations: DESTRUCTIVE,
4611
4594
  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.`,
4612
4595
  inputSchema: z8.object({
4613
4596
  groupId: z8.string().uuid().describe("Policy group ID"),
@@ -4621,7 +4604,7 @@ function registerDeployTools(server, callApi) {
4621
4604
  "lexq_deploy_live",
4622
4605
  {
4623
4606
  title: "Deploy to Live",
4624
- annotations: { readOnlyHint: false, destructiveHint: true },
4607
+ annotations: DESTRUCTIVE,
4625
4608
  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.",
4626
4609
  inputSchema: z8.object({
4627
4610
  groupId: z8.string().uuid().describe("Policy group ID"),
@@ -4637,7 +4620,7 @@ function registerDeployTools(server, callApi) {
4637
4620
  "lexq_deploy_rollback",
4638
4621
  {
4639
4622
  title: "Rollback Deployment",
4640
- annotations: { readOnlyHint: false, destructiveHint: true },
4623
+ annotations: DESTRUCTIVE,
4641
4624
  description: dedent18`
4642
4625
  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).
4643
4626
 
@@ -4658,7 +4641,7 @@ function registerDeployTools(server, callApi) {
4658
4641
  "lexq_deploy_undeploy",
4659
4642
  {
4660
4643
  title: "Undeploy",
4661
- annotations: { readOnlyHint: false, destructiveHint: true },
4644
+ annotations: DESTRUCTIVE,
4662
4645
  description: "Remove the live version from traffic. The version stays ACTIVE but no longer serves requests.",
4663
4646
  inputSchema: z8.object({
4664
4647
  groupId: z8.string().uuid().describe("Policy group ID"),
@@ -4673,7 +4656,7 @@ function registerDeployTools(server, callApi) {
4673
4656
  "lexq_deploy_schedule",
4674
4657
  {
4675
4658
  title: "Schedule Deployment",
4676
- annotations: { readOnlyHint: false, destructiveHint: false },
4659
+ annotations: DESTRUCTIVE,
4677
4660
  description: "Schedule an ACTIVE version with a future effective start date to auto-deploy at that time (Scheduled Deployment). The time is the version's effectiveFrom, set on the DRAFT before publishing; this call takes no time of its own. One pending schedule per group; manual deploy/rollback/undeploy, starting an A/B test, or archiving the group cancels it. The snapshot hash is sealed at scheduling and re-verified at execution (fail-closed).",
4678
4661
  inputSchema: z8.object({
4679
4662
  groupId: z8.string().uuid().describe("Policy group ID"),
@@ -4689,7 +4672,7 @@ function registerDeployTools(server, callApi) {
4689
4672
  "lexq_deploy_unschedule",
4690
4673
  {
4691
4674
  title: "Cancel Scheduled Deployment",
4692
- annotations: { readOnlyHint: false, destructiveHint: true },
4675
+ annotations: DESTRUCTIVE,
4693
4676
  description: "Cancel the pending scheduled deployment for a group. The version itself is not affected. Fails with P-039 if no pending schedule exists.",
4694
4677
  inputSchema: z8.object({
4695
4678
  groupId: z8.string().uuid().describe("Policy group ID")
@@ -4701,8 +4684,8 @@ function registerDeployTools(server, callApi) {
4701
4684
  "lexq_deploy_schedules",
4702
4685
  {
4703
4686
  title: "List Scheduled Deployments",
4704
- annotations: { readOnlyHint: true },
4705
- description: "List scheduled deployments across all groups (all statuses: PENDING, EXECUTED, CANCELED, FAILED), newest first.",
4687
+ annotations: READ_ONLY,
4688
+ description: `List scheduled deployments across all groups (all statuses: ${DeploymentScheduleStatus.join(", ")}), newest first.`,
4706
4689
  inputSchema: z8.object({
4707
4690
  page: z8.number().int().min(0).default(0).describe("Page number"),
4708
4691
  size: z8.number().int().min(1).max(100).default(20).describe("Page size")
@@ -4714,13 +4697,13 @@ function registerDeployTools(server, callApi) {
4714
4697
  "lexq_deploy_history",
4715
4698
  {
4716
4699
  title: "Deployment History",
4717
- annotations: { readOnlyHint: true },
4700
+ annotations: READ_ONLY,
4718
4701
  description: "List deployment history across all groups.",
4719
4702
  inputSchema: z8.object({
4720
4703
  page: z8.number().int().min(0).default(0).describe("Page number"),
4721
4704
  size: z8.number().int().min(1).max(100).default(20).describe("Page size"),
4722
4705
  groupId: z8.string().uuid().optional().describe("Filter by group ID"),
4723
- types: z8.string().optional().describe("Filter by deployment types (comma-separated: DEPLOY,ROLLBACK,UNDEPLOY)"),
4706
+ types: z8.string().optional().describe(`Filter by deployment types (comma-separated: ${DeploymentType.join(",")})`),
4724
4707
  startDate: z8.string().optional().describe(START_DAY),
4725
4708
  endDate: z8.string().optional().describe(END_DAY)
4726
4709
  })
@@ -4738,7 +4721,7 @@ function registerDeployTools(server, callApi) {
4738
4721
  "lexq_deploy_detail",
4739
4722
  {
4740
4723
  title: "Deployment Detail",
4741
- annotations: { readOnlyHint: true },
4724
+ annotations: READ_ONLY,
4742
4725
  description: "Get detailed info about a specific deployment including snapshot hash and integrity check.",
4743
4726
  inputSchema: z8.object({
4744
4727
  deploymentId: z8.string().uuid().describe("Deployment ID")
@@ -4750,7 +4733,7 @@ function registerDeployTools(server, callApi) {
4750
4733
  "lexq_deploy_overview",
4751
4734
  {
4752
4735
  title: "Deployment Overview",
4753
- annotations: { readOnlyHint: true },
4736
+ annotations: READ_ONLY,
4754
4737
  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.",
4755
4738
  inputSchema: z8.object({})
4756
4739
  },
@@ -4760,7 +4743,7 @@ function registerDeployTools(server, callApi) {
4760
4743
  "lexq_deploy_deployable",
4761
4744
  {
4762
4745
  title: "List Deployable Versions",
4763
- annotations: { readOnlyHint: true },
4746
+ annotations: READ_ONLY,
4764
4747
  description: "List ACTIVE (published) versions that can be deployed for a group. Use this to find which versions are available before calling deploy live.",
4765
4748
  inputSchema: z8.object({
4766
4749
  groupId: z8.string().uuid().describe("Policy group ID")
@@ -4772,7 +4755,7 @@ function registerDeployTools(server, callApi) {
4772
4755
  "lexq_deploy_diff",
4773
4756
  {
4774
4757
  title: "Deployment Diff",
4775
- annotations: { readOnlyHint: true },
4758
+ annotations: READ_ONLY,
4776
4759
  description: "Compare rule snapshots between two versions. Shows added, removed, and modified rules. Useful for reviewing changes before deploying a new version.",
4777
4760
  inputSchema: z8.object({
4778
4761
  baseVersionId: z8.string().uuid().describe("Base version ID (typically the current live)"),
@@ -4801,8 +4784,8 @@ function registerProfileTools(server, callApi) {
4801
4784
  "lexq_profile_overview",
4802
4785
  {
4803
4786
  title: "Group Latency Profile",
4804
- annotations: { readOnlyHint: true },
4805
- 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 6, p95 n \u2265 60, p99 n \u2265 300 \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.`,
4787
+ annotations: READ_ONLY,
4788
+ description: "Per-rule latency profile of a policy group over a time window: group TOTAL distribution split by cache state (HIT = compiled ruleset cache hit, MISS = deep-load + compile), a per-rule CONDITION/ACTION percentile table, and slow-rule flags. " + RELATIVE_THRESHOLD + ` Every percentile is accompanied by its sample count n; a percentile is withheld (null) unless n\xD7(1\u2212q) \u2265 ${TAIL_MIN_OBS} (p50 needs n \u2265 ${PERCENTILE_MIN_N.p50}, p95 n \u2265 ${PERCENTILE_MIN_N.p95}, p99 n \u2265 ${PERCENTILE_MIN_N.p99} \u2014 display gate, separate from the n \u2265 ${MIN_SAMPLES} judgment gate). Baselines report INSUFFICIENT_COHORT when fewer than ${MIN_COHORT_SIZE} rules qualify. Rule detail comes from a deterministic ${PROFILE_SAMPLE_PERMILLE / 10}% sample of calls; TOTAL is recorded for every call. Defaults: last ${PROFILE_DEFAULT_WINDOW_HOURS}h, live version, cacheState HIT.`,
4806
4789
  inputSchema: z9.object({
4807
4790
  groupId: z9.string().uuid().describe("Policy group ID"),
4808
4791
  versionId: z9.string().uuid().optional().describe("Version to inspect (default: live version)"),
@@ -4810,7 +4793,7 @@ function registerProfileTools(server, callApi) {
4810
4793
  `Window start, ISO-8601 instant (e.g. 2026-07-01T00:00:00Z). Default: ${PROFILE_DEFAULT_WINDOW_HOURS}h ago`
4811
4794
  ),
4812
4795
  to: z9.string().optional().describe("Window end, ISO-8601 instant. Default: now"),
4813
- cacheState: z9.enum(["HIT", "MISS"]).optional().describe("Cache dimension for the rule table and judgment (default: HIT)")
4796
+ cacheState: z9.enum(ProfileCacheState).optional().describe("Cache dimension for the rule table and judgment (default: HIT)")
4814
4797
  })
4815
4798
  },
4816
4799
  async ({ groupId, versionId, from, to, cacheState }) => callApi("GET", `policy-groups/${groupId}/profile`, {
@@ -4821,8 +4804,8 @@ function registerProfileTools(server, callApi) {
4821
4804
  "lexq_profile_rule",
4822
4805
  {
4823
4806
  title: "Rule Latency Detail",
4824
- annotations: { readOnlyHint: true },
4825
- 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 6, p95 n \u2265 60, p99 n \u2265 300). ` + RELATIVE_THRESHOLD,
4807
+ annotations: READ_ONLY,
4808
+ 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,
4826
4809
  inputSchema: z9.object({
4827
4810
  groupId: z9.string().uuid().describe("Policy group ID"),
4828
4811
  ruleId: z9.string().uuid().describe("Rule ID (from lexq_profile_overview)"),
@@ -4846,7 +4829,7 @@ function registerReplayTools(server, callApi) {
4846
4829
  "lexq_replay_decision",
4847
4830
  {
4848
4831
  title: "Replay a Decision",
4849
- annotations: { readOnlyHint: true },
4832
+ annotations: READ_ONLY,
4850
4833
  description: "Re-evaluate a past execution (traceId) against a candidate version and return the decision diff (decisionChanged, effect changes, fired rules) plus a determinism verdict. Synchronous and free of charge (TPS throttle only). A replay sends no webhook, notification, or event: rule actions produce no outward effects.",
4851
4834
  inputSchema: z10.object({
4852
4835
  traceId: z10.string().describe("Trace ID of the past execution to replay"),
@@ -4859,7 +4842,7 @@ function registerReplayTools(server, callApi) {
4859
4842
  "lexq_replay_start",
4860
4843
  {
4861
4844
  title: "Start Window Replay (Blast Radius)",
4862
- annotations: { readOnlyHint: false, destructiveHint: false },
4845
+ annotations: DESTRUCTIVE,
4863
4846
  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.",
4864
4847
  inputSchema: z10.object({
4865
4848
  candidateVersionId: z10.string().uuid().describe("Version to re-evaluate against"),
@@ -4876,7 +4859,7 @@ function registerReplayTools(server, callApi) {
4876
4859
  "lexq_replay_status",
4877
4860
  {
4878
4861
  title: "Get Replay Job Status",
4879
- annotations: { readOnlyHint: true },
4862
+ annotations: READ_ONLY,
4880
4863
  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.",
4881
4864
  inputSchema: z10.object({
4882
4865
  jobId: z10.string().describe("Replay job ID from lexq_replay_start")
@@ -4888,7 +4871,7 @@ function registerReplayTools(server, callApi) {
4888
4871
  "lexq_replay_list",
4889
4872
  {
4890
4873
  title: "List Replay Jobs",
4891
- annotations: { readOnlyHint: true },
4874
+ annotations: READ_ONLY,
4892
4875
  description: "List window replay job history (reverse-chronological). Lightweight items \u2014 use lexq_replay_status for summary and changed samples.",
4893
4876
  inputSchema: z10.object({
4894
4877
  page: z10.number().int().min(0).default(0).describe("Page number"),
@@ -4901,7 +4884,7 @@ function registerReplayTools(server, callApi) {
4901
4884
  "lexq_replay_cancel",
4902
4885
  {
4903
4886
  title: "Cancel Replay Job",
4904
- annotations: { readOnlyHint: false, destructiveHint: true },
4887
+ annotations: DESTRUCTIVE,
4905
4888
  description: "Cooperatively cancel a PENDING or RUNNING window replay job. Other states are rejected. VIEWER role cannot cancel.",
4906
4889
  inputSchema: z10.object({
4907
4890
  jobId: z10.string().describe("Replay job ID")
@@ -4913,14 +4896,17 @@ function registerReplayTools(server, callApi) {
4913
4896
  "lexq_replay_export",
4914
4897
  {
4915
4898
  title: "Export Replay Result",
4916
- annotations: { readOnlyHint: true },
4917
- 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.",
4899
+ annotations: READ_ONLY,
4900
+ description: `Export a COMPLETED window replay job. A running job is rejected \u2014 a partial result reads as the whole thing on the receiving end. The two formats carry different things: CSV holds the effect blast radius, whose columns are fixed; JSON holds the changed samples and action parameters, which nest and whose keys differ per tenant. JSON is not a superset of CSV. Returns the file contents as text. ${EXPORT_LIMIT_NOTE}`,
4918
4901
  inputSchema: z10.object({
4919
4902
  jobId: z10.string().describe("Replay job ID"),
4920
4903
  format: z10.enum(EXPORT_FORMATS).default("json").describe("Export format")
4921
4904
  })
4922
4905
  },
4923
- async ({ jobId, format }) => callApi("GET", `replay/jobs/${jobId}/export`, { params: { format } })
4906
+ async ({ jobId, format }) => limitExport(
4907
+ await callApi("GET", `replay/jobs/${jobId}/export`, { params: { format } }),
4908
+ `lexq replay export --id ${jobId} --as ${format} --output <file>`
4909
+ )
4924
4910
  );
4925
4911
  }
4926
4912
 
@@ -4931,7 +4917,7 @@ function registerHistoryTools(server, callApi) {
4931
4917
  "lexq_history_list",
4932
4918
  {
4933
4919
  title: "List Execution History",
4934
- annotations: { readOnlyHint: true },
4920
+ annotations: READ_ONLY,
4935
4921
  description: "List policy execution history. Shows trace ID, group, version, status, match result, and latency.",
4936
4922
  inputSchema: z11.object({
4937
4923
  page: z11.number().int().min(0).default(0).describe("Page number"),
@@ -4939,7 +4925,7 @@ function registerHistoryTools(server, callApi) {
4939
4925
  traceId: z11.string().optional().describe("Filter by trace ID"),
4940
4926
  groupId: z11.string().uuid().optional().describe("Filter by policy group"),
4941
4927
  versionId: z11.string().uuid().optional().describe("Filter by version"),
4942
- status: z11.enum(["SUCCESS", "NO_MATCH", "ERROR", "TIMEOUT"]).optional().describe("Filter by execution status"),
4928
+ status: z11.enum(ApiExecutionStatus).optional().describe("Filter by execution status"),
4943
4929
  startDate: z11.string().optional().describe(START_DAY),
4944
4930
  endDate: z11.string().optional().describe(END_DAY)
4945
4931
  })
@@ -4959,7 +4945,7 @@ function registerHistoryTools(server, callApi) {
4959
4945
  "lexq_history_get",
4960
4946
  {
4961
4947
  title: "Get Execution Detail",
4962
- annotations: { readOnlyHint: true },
4948
+ annotations: READ_ONLY,
4963
4949
  description: "Get full execution detail including inputFacts, mutatedFacts, generatedVariables, executionTraces, and decisionTraces.",
4964
4950
  inputSchema: z11.object({
4965
4951
  traceId: z11.string().describe("Trace ID from execution history")
@@ -4971,7 +4957,7 @@ function registerHistoryTools(server, callApi) {
4971
4957
  "lexq_history_stats",
4972
4958
  {
4973
4959
  title: "Execution Statistics",
4974
- annotations: { readOnlyHint: true },
4960
+ annotations: READ_ONLY,
4975
4961
  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.`,
4976
4962
  inputSchema: z11.object({
4977
4963
  groupId: z11.string().uuid().optional().describe("Filter by policy group"),
@@ -4996,7 +4982,7 @@ function registerProvenanceTools(server, callApi) {
4996
4982
  "lexq_provenance_get",
4997
4983
  {
4998
4984
  title: "Get Decision Provenance",
4999
- annotations: { readOnlyHint: true },
4985
+ annotations: READ_ONLY,
5000
4986
  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.",
5001
4987
  inputSchema: z12.object({
5002
4988
  traceId: z12.string().describe("Trace ID of the execution")
@@ -5008,7 +4994,7 @@ function registerProvenanceTools(server, callApi) {
5008
4994
  "lexq_pii_reveals_list",
5009
4995
  {
5010
4996
  title: "List PII Reveal Audits",
5011
- annotations: { readOnlyHint: true },
4997
+ annotations: READ_ONLY,
5012
4998
  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.",
5013
4999
  inputSchema: z12.object({
5014
5000
  page: z12.number().int().min(0).default(0).describe("Page number"),
@@ -5039,8 +5025,8 @@ function registerLogTools(server, callApi) {
5039
5025
  "lexq_logs_list",
5040
5026
  {
5041
5027
  title: "List Failure Logs",
5042
- annotations: { readOnlyHint: true },
5043
- 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.`,
5028
+ annotations: READ_ONLY,
5029
+ 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}`,
5044
5030
  inputSchema: z13.object({
5045
5031
  page: z13.number().int().min(0).default(0).describe("Page number"),
5046
5032
  size: z13.number().int().min(1).max(100).default(20).describe("Page size"),
@@ -5065,54 +5051,28 @@ function registerLogTools(server, callApi) {
5065
5051
  "lexq_logs_get",
5066
5052
  {
5067
5053
  title: "Get Failure Log",
5068
- annotations: { readOnlyHint: true },
5069
- description: "Get failure log detail by ID.",
5054
+ annotations: READ_ONLY,
5055
+ description: `Get failure log detail by ID. ${FAILURE_LOG_CONSOLE_NOTE}`,
5070
5056
  inputSchema: z13.object({
5071
5057
  logId: z13.string().uuid().describe("Failure log ID")
5072
5058
  })
5073
5059
  },
5074
5060
  async ({ logId }) => callApi("GET", `failure-logs/${logId}`)
5075
5061
  );
5076
- server.registerTool(
5077
- "lexq_logs_action",
5078
- {
5079
- title: "Process Failure Log",
5080
- annotations: { readOnlyHint: false, destructiveHint: false },
5081
- description: "Process a single failure log: RESOLVE (mark as manually fixed) or IGNORE (skip intentionally).",
5082
- inputSchema: z13.object({
5083
- logId: z13.string().uuid().describe("Failure log ID"),
5084
- action: z13.enum(FailureAction).describe("Action to take")
5085
- })
5086
- },
5087
- async ({ logId, action }) => callApi("POST", `failure-logs/${logId}/actions`, {
5088
- params: { action }
5089
- })
5090
- );
5091
- server.registerTool(
5092
- "lexq_logs_bulk_action",
5093
- {
5094
- title: "Bulk Process Failure Logs",
5095
- annotations: { readOnlyHint: false, destructiveHint: false },
5096
- description: "Process multiple failure logs at once. Provide an array of log IDs and the action.",
5097
- inputSchema: z13.object({
5098
- logIds: z13.array(z13.string().uuid()).describe("Array of failure log IDs"),
5099
- action: z13.enum(FailureAction).describe("Action to apply to all logs")
5100
- })
5101
- },
5102
- async ({ logIds, action }) => callApi("POST", "failure-logs/bulk-actions", {
5103
- body: { logIds, action }
5104
- })
5105
- );
5106
5062
  }
5107
5063
 
5108
5064
  // src/mcp/tools/webhook-subscriptions.ts
5109
5065
  import { z as z14 } from "zod";
5066
+ var FORMAT_NOTES2 = {
5067
+ GENERIC: "full JSON",
5068
+ SLACK: '{"text": "..."}'
5069
+ };
5110
5070
  function registerWebhookSubscriptionTools(server, callApi) {
5111
5071
  server.registerTool(
5112
5072
  "lexq_webhook_subscriptions_list",
5113
5073
  {
5114
5074
  title: "List Webhook Subscriptions",
5115
- annotations: { readOnlyHint: true },
5075
+ annotations: READ_ONLY,
5116
5076
  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.",
5117
5077
  inputSchema: z14.object({
5118
5078
  page: z14.number().int().min(0).default(0).describe("Page number"),
@@ -5128,7 +5088,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
5128
5088
  "lexq_webhook_subscriptions_get",
5129
5089
  {
5130
5090
  title: "Get Webhook Subscription",
5131
- annotations: { readOnlyHint: true },
5091
+ annotations: READ_ONLY,
5132
5092
  description: "Get webhook subscription detail by ID.",
5133
5093
  inputSchema: z14.object({
5134
5094
  id: z14.string().uuid().describe("Webhook subscription ID")
@@ -5140,8 +5100,8 @@ function registerWebhookSubscriptionTools(server, callApi) {
5140
5100
  "lexq_webhook_subscriptions_save",
5141
5101
  {
5142
5102
  title: "Save Webhook Subscription",
5143
- annotations: { readOnlyHint: false, destructiveHint: true },
5144
- description: `Create or update a webhook subscription. Omit id to create, provide id to update. Events: ${PlatformEventType.join(", ")}. Formats: GENERIC (full JSON), SLACK ({"text": "..."}).`,
5103
+ annotations: { ...DESTRUCTIVE, openWorldHint: true },
5104
+ description: `Create or update a webhook subscription. Omit id to create, provide id to update. Events: ${PlatformEventType.join(", ")}. Formats: ${withNotes(FORMAT_NOTES2).join(", ")}.`,
5145
5105
  inputSchema: z14.object({
5146
5106
  id: z14.string().uuid().optional().describe("Subscription ID (omit to create, provide to update)"),
5147
5107
  name: z14.string().min(1).describe("Subscription name (unique per tenant)"),
@@ -5158,7 +5118,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
5158
5118
  "lexq_webhook_subscriptions_delete",
5159
5119
  {
5160
5120
  title: "Delete Webhook Subscription",
5161
- annotations: { readOnlyHint: false, destructiveHint: true },
5121
+ annotations: DESTRUCTIVE,
5162
5122
  description: "Delete a webhook subscription by ID.",
5163
5123
  inputSchema: z14.object({
5164
5124
  id: z14.string().uuid().describe("Webhook subscription ID")
@@ -5170,7 +5130,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
5170
5130
  "lexq_webhook_subscriptions_test",
5171
5131
  {
5172
5132
  title: "Test Webhook Subscription",
5173
- annotations: { readOnlyHint: false, destructiveHint: false },
5133
+ annotations: { ...DESTRUCTIVE, openWorldHint: true },
5174
5134
  description: "Send a test event to verify webhook connectivity. Returns the HTTP status code and success/failure message.",
5175
5135
  inputSchema: z14.object({
5176
5136
  id: z14.string().uuid().describe("Webhook subscription ID")
@@ -5187,7 +5147,7 @@ function registerDomainTemplateTools(server, callApi) {
5187
5147
  "lexq_domain_templates_list",
5188
5148
  {
5189
5149
  title: "List Domain Templates",
5190
- annotations: { readOnlyHint: true },
5150
+ annotations: READ_ONLY,
5191
5151
  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.",
5192
5152
  inputSchema: z15.object({})
5193
5153
  },
@@ -5197,7 +5157,7 @@ function registerDomainTemplateTools(server, callApi) {
5197
5157
  "lexq_domain_templates_preview",
5198
5158
  {
5199
5159
  title: "Preview Domain Template",
5200
- annotations: { readOnlyHint: true },
5160
+ annotations: READ_ONLY,
5201
5161
  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.",
5202
5162
  inputSchema: z15.object({
5203
5163
  template: z15.string().describe(
@@ -5211,7 +5171,7 @@ function registerDomainTemplateTools(server, callApi) {
5211
5171
  "lexq_domain_templates_apply",
5212
5172
  {
5213
5173
  title: "Apply Domain Template",
5214
- annotations: { readOnlyHint: false, destructiveHint: false },
5174
+ annotations: ADDITIVE,
5215
5175
  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.",
5216
5176
  inputSchema: z15.object({
5217
5177
  template: z15.string().describe("Domain template key to apply (e.g. ECOMMERCE)."),
@@ -5246,6 +5206,13 @@ function registerAllTools(server, callApi) {
5246
5206
  registerWebhookSubscriptionTools(server, callApi);
5247
5207
  }
5248
5208
  var SERVER_INFO = { name: "lexq", ...server_info_default };
5209
+ var SERVER_INSTRUCTIONS = [
5210
+ "LexQ evaluates business rules: a policy group holds versions, a version holds rules, and rules read input facts.",
5211
+ "- 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.",
5212
+ "- 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.",
5213
+ "- Before lexq_deploy_publish, run lexq_dry_run on the version with representative facts. A published version cannot go back to draft.",
5214
+ `- For anything these tools cannot do, point the user to the LexQ console at ${CONSOLE_URL}.`
5215
+ ].join("\n");
5249
5216
 
5250
5217
  // src/mcp/server.ts
5251
5218
  var __dirname = dirname(fileURLToPath(import.meta.url));
@@ -5262,7 +5229,10 @@ function startMcpServer() {
5262
5229
  const callApi = createCallApiFromConfig();
5263
5230
  serveStdio(
5264
5231
  () => {
5265
- const server = new McpServer({ ...SERVER_INFO, version });
5232
+ const server = new McpServer(
5233
+ { ...SERVER_INFO, version },
5234
+ { instructions: SERVER_INSTRUCTIONS }
5235
+ );
5266
5236
  registerAllTools(server, callApi);
5267
5237
  registerLocalTools(server);
5268
5238
  return server;