@lexq/cli 2.1.1 → 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.
@@ -12,6 +12,135 @@ var server_info_default = {
12
12
  ]
13
13
  };
14
14
 
15
+ // src/lib/console.ts
16
+ var CONSOLE_URL = "https://console.lexq.io";
17
+ 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).`;
18
+
19
+ // src/lib/config.ts
20
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
21
+ import { homedir } from "os";
22
+ import { join } from "path";
23
+ var CONFIG_DIR = join(homedir(), ".lexq");
24
+ var CONFIG_FILE = join(CONFIG_DIR, "config.json");
25
+
26
+ // src/lib/lossless-json.ts
27
+ import {
28
+ LosslessNumber,
29
+ isSafeNumber,
30
+ parse as losslessParse,
31
+ stringify as losslessStringify
32
+ } from "lossless-json";
33
+ var survivesDoubleRoundTrip = (literal) => isSafeNumber(literal);
34
+ var parseNumber = (value) => survivesDoubleRoundTrip(value) ? parseFloat(value) : new LosslessNumber(value);
35
+ var parseJson = (text) => losslessParse(text, void 0, parseNumber);
36
+ var stringifyJson = (value, space) => losslessStringify(value, void 0, space);
37
+ var isLosslessNumber = (value) => value instanceof LosslessNumber;
38
+
39
+ // src/lib/api-client.ts
40
+ var ApiError = class extends Error {
41
+ constructor(statusCode, errorCode, message) {
42
+ super(message);
43
+ this.statusCode = statusCode;
44
+ this.errorCode = errorCode;
45
+ this.name = "ApiError";
46
+ }
47
+ statusCode;
48
+ errorCode;
49
+ };
50
+
51
+ // src/lib/value-domain-warning.ts
52
+ function formatValueDomainMismatchWarning(meta) {
53
+ const mismatch = meta?.valueDomainMismatch;
54
+ if (!mismatch || !Array.isArray(mismatch.versions) || mismatch.versions.length === 0) return null;
55
+ const n = mismatch.totalRuleCount;
56
+ const subject = n === 1 ? "1 rule compares" : `${n} rules compare`;
57
+ const lines = [
58
+ `\u26A0 ${subject} ${mismatch.factKey} with values the new value domain does not accept. The update was still saved.`
59
+ ];
60
+ for (const version of mismatch.versions) {
61
+ lines.push(...versionLines(mismatch.factKey, version));
62
+ }
63
+ return lines.join("\n");
64
+ }
65
+ function versionLines(factKey, version) {
66
+ const draft = version.versionStatus === "DRAFT";
67
+ const lines = [
68
+ ` ${quoted(version.groupName)} v${version.versionNo}, ${draft ? "draft" : "deployed"} (groupId ${version.groupId}, versionId ${version.versionId})`,
69
+ draft ? " Dry runs of this draft already reject calls that send these values (P-052), and live calls will too once it is published and deployed. Saving one of these rules again is refused (P-051) until each listed condition is edited or removed; first check whether each one is still needed." : " Calls to it are checked against the fact definitions captured when it was published, so this change does not affect them. A version cloned from it carries these rules; fix them in the clone before publishing it."
70
+ ];
71
+ for (const rule of version.rules) {
72
+ const conditions = rule.conditions.map(
73
+ (c) => `${factKey} ${c.operator} ${escapeUnsafe(stringifyJson(c.value) ?? String(c.value))}`
74
+ ).join("; ");
75
+ lines.push(` \u2022 ${quoted(rule.ruleName)} (ruleId ${rule.ruleId}): ${conditions}`);
76
+ }
77
+ const hidden = version.ruleCount - version.rules.length;
78
+ if (hidden > 0) {
79
+ lines.push(` \u2026 and ${hidden} more rule${hidden === 1 ? "" : "s"} in this version.`);
80
+ }
81
+ return lines;
82
+ }
83
+ function quoted(name) {
84
+ return escapeUnsafe(stringifyJson(String(name)) ?? '""');
85
+ }
86
+ var UNSAFE = /[\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
87
+ function escapeUnsafe(json) {
88
+ return json.replace(UNSAFE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
89
+ }
90
+
91
+ // src/mcp/tools/_shared.ts
92
+ var READ_ONLY = {
93
+ readOnlyHint: true,
94
+ destructiveHint: false,
95
+ openWorldHint: false
96
+ };
97
+ var ADDITIVE = {
98
+ readOnlyHint: false,
99
+ destructiveHint: false,
100
+ openWorldHint: false
101
+ };
102
+ var DESTRUCTIVE = {
103
+ readOnlyHint: false,
104
+ destructiveHint: true,
105
+ openWorldHint: false
106
+ };
107
+ function paginationParams(page, size) {
108
+ const params = {};
109
+ if (page !== void 0) params.page = String(page);
110
+ if (size !== void 0) params.size = String(size);
111
+ return params;
112
+ }
113
+ var MAX_EXPORT_CHARS = 1e5;
114
+ var formatCount = (count) => count.toLocaleString("en-US");
115
+ var EXPORT_LIMIT_NOTE = `An export longer than ${formatCount(MAX_EXPORT_CHARS)} characters is refused.`;
116
+ function limitExport(result, command, narrower) {
117
+ if (result.isError) return result;
118
+ const length = result.content.reduce((sum, block) => sum + block.text.length, 0);
119
+ if (length <= MAX_EXPORT_CHARS) return result;
120
+ const text = [
121
+ `The export is ${formatCount(length)} characters long, and this tool returns at most ${formatCount(MAX_EXPORT_CHARS)}.`,
122
+ narrower,
123
+ `To get the whole file, download it from the LexQ console (${CONSOLE_URL}) or with \`${command}\`.`
124
+ ].filter(Boolean).join(" ");
125
+ return { content: [{ type: "text", text }], isError: true };
126
+ }
127
+ function formatUnregisteredFactWarning(meta) {
128
+ const facts = meta?.unregisteredFacts ?? [];
129
+ if (facts.length === 0) return null;
130
+ const lines = facts.map((f) => {
131
+ if (f.inferredType) {
132
+ return ` \u2022 ${f.key} (${f.inferredType}) \u2014 register: lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: "${f.inferredType}" })`;
133
+ }
134
+ const choices = f.candidateTypes?.join(" | ") ?? "STRING | NUMBER | BOOLEAN";
135
+ return ` \u2022 ${f.key} (ambiguous) \u2014 pick a type (${choices}), then lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: <chosen> })`;
136
+ });
137
+ return [
138
+ `\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.`,
139
+ ...lines,
140
+ `Register them before the version is published to have those values checked, or call lexq_facts_unregistered to review the version's full list.`
141
+ ].join("\n");
142
+ }
143
+
15
144
  // src/mcp/tools/status.ts
16
145
  import { z } from "zod";
17
146
  function registerStatusTools(server, callApi) {
@@ -19,7 +148,7 @@ function registerStatusTools(server, callApi) {
19
148
  "lexq_whoami",
20
149
  {
21
150
  title: "Who Am I",
22
- annotations: { readOnlyHint: true },
151
+ annotations: READ_ONLY,
23
152
  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.',
24
153
  inputSchema: z.object({})
25
154
  },
@@ -54,7 +183,6 @@ var SimulationStatus = ["PENDING", "RUNNING", "COMPLETED", "FAILED", "CANCELED"]
54
183
  var SimulationMetricType = ["COUNT", "SUM", "AVG"];
55
184
  var ExportFormat = ["CSV", "JSON"];
56
185
  var FailureStatus = ["PENDING", "RESOLVED", "IGNORED"];
57
- var FailureAction = ["IGNORE", "RESOLVE"];
58
186
  var TaskType = ["PLATFORM_WEBHOOK", "SCHEDULED_DEPLOYMENT"];
59
187
  var PlatformEventType = [
60
188
  "VERSION_PUBLISHED",
@@ -98,7 +226,7 @@ function registerGroupTools(server, callApi) {
98
226
  "lexq_groups_list",
99
227
  {
100
228
  title: "List Policy Groups",
101
- annotations: { readOnlyHint: true },
229
+ annotations: READ_ONLY,
102
230
  description: "List policy groups (tenant-wide, priority ASC). Deleted groups are not included.",
103
231
  inputSchema: z2.object({})
104
232
  },
@@ -108,7 +236,7 @@ function registerGroupTools(server, callApi) {
108
236
  "lexq_groups_get",
109
237
  {
110
238
  title: "Get Policy Group",
111
- annotations: { readOnlyHint: true },
239
+ annotations: READ_ONLY,
112
240
  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.",
113
241
  inputSchema: z2.object({
114
242
  groupId: z2.string().uuid().describe("Policy group ID")
@@ -120,7 +248,7 @@ function registerGroupTools(server, callApi) {
120
248
  "lexq_groups_create",
121
249
  {
122
250
  title: "Create Policy Group",
123
- annotations: { readOnlyHint: false, destructiveHint: false },
251
+ annotations: ADDITIVE,
124
252
  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.`,
125
253
  inputSchema: z2.object({
126
254
  name: z2.string().describe("Group name (unique among non-ARCHIVED)"),
@@ -151,7 +279,7 @@ function registerGroupTools(server, callApi) {
151
279
  "lexq_groups_update",
152
280
  {
153
281
  title: "Update Policy Group",
154
- annotations: { readOnlyHint: false, destructiveHint: true },
282
+ annotations: DESTRUCTIVE,
155
283
  description: "Update a policy group. Only provided fields are updated; omitted fields remain unchanged. A deleted group cannot be updated (409 P-059).",
156
284
  inputSchema: z2.object({
157
285
  groupId: z2.string().uuid().describe("Policy group ID"),
@@ -176,7 +304,7 @@ function registerGroupTools(server, callApi) {
176
304
  "lexq_groups_delete",
177
305
  {
178
306
  title: "Delete Policy Group",
179
- annotations: { readOnlyHint: false, destructiveHint: true },
307
+ annotations: DESTRUCTIVE,
180
308
  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.`,
181
309
  inputSchema: z2.object({
182
310
  groupId: z2.string().uuid().describe("Policy group ID")
@@ -188,7 +316,7 @@ function registerGroupTools(server, callApi) {
188
316
  "lexq_groups_reorder",
189
317
  {
190
318
  title: "Reorder Policy Groups",
191
- annotations: { readOnlyHint: false, destructiveHint: true },
319
+ annotations: DESTRUCTIVE,
192
320
  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.",
193
321
  inputSchema: z2.object({
194
322
  groupIds: z2.array(z2.string().uuid()).describe("Group IDs in desired priority order (index 0 = priority 1)")
@@ -206,8 +334,8 @@ function registerGroupTools(server, callApi) {
206
334
  "lexq_ab_test_start",
207
335
  {
208
336
  title: "Start A/B Test",
209
- annotations: { readOnlyHint: false, destructiveHint: false },
210
- 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%.",
337
+ annotations: DESTRUCTIVE,
338
+ 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.",
211
339
  inputSchema: z2.object({
212
340
  groupId: z2.string().uuid().describe("Policy group ID"),
213
341
  testVersionId: z2.string().uuid().describe("Challenger version ID. Must be a published version of this group"),
@@ -222,7 +350,7 @@ function registerGroupTools(server, callApi) {
222
350
  "lexq_ab_test_stop",
223
351
  {
224
352
  title: "Stop A/B Test",
225
- annotations: { readOnlyHint: false, destructiveHint: true },
353
+ annotations: DESTRUCTIVE,
226
354
  description: "Stop a running A/B test. All traffic is restored to the control (current) version.",
227
355
  inputSchema: z2.object({
228
356
  groupId: z2.string().uuid().describe("Policy group ID")
@@ -234,7 +362,7 @@ function registerGroupTools(server, callApi) {
234
362
  "lexq_ab_test_adjust",
235
363
  {
236
364
  title: "Adjust A/B Test",
237
- annotations: { readOnlyHint: false, destructiveHint: true },
365
+ annotations: DESTRUCTIVE,
238
366
  description: "Adjust traffic rate of a running A/B test.",
239
367
  inputSchema: z2.object({
240
368
  groupId: z2.string().uuid().describe("Policy group ID"),
@@ -250,102 +378,6 @@ function registerGroupTools(server, callApi) {
250
378
  // src/mcp/tools/versions.ts
251
379
  import { z as z3 } from "zod";
252
380
 
253
- // src/lib/config.ts
254
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "fs";
255
- import { homedir } from "os";
256
- import { join } from "path";
257
- var CONFIG_DIR = join(homedir(), ".lexq");
258
- var CONFIG_FILE = join(CONFIG_DIR, "config.json");
259
-
260
- // src/lib/lossless-json.ts
261
- import {
262
- LosslessNumber,
263
- isSafeNumber,
264
- parse as losslessParse,
265
- stringify as losslessStringify
266
- } from "lossless-json";
267
- var survivesDoubleRoundTrip = (literal) => isSafeNumber(literal);
268
- var parseNumber = (value) => survivesDoubleRoundTrip(value) ? parseFloat(value) : new LosslessNumber(value);
269
- var parseJson = (text) => losslessParse(text, void 0, parseNumber);
270
- var stringifyJson = (value, space) => losslessStringify(value, void 0, space);
271
- var isLosslessNumber = (value) => value instanceof LosslessNumber;
272
-
273
- // src/lib/api-client.ts
274
- var ApiError = class extends Error {
275
- constructor(statusCode, errorCode, message) {
276
- super(message);
277
- this.statusCode = statusCode;
278
- this.errorCode = errorCode;
279
- this.name = "ApiError";
280
- }
281
- statusCode;
282
- errorCode;
283
- };
284
-
285
- // src/lib/value-domain-warning.ts
286
- function formatValueDomainMismatchWarning(meta) {
287
- const mismatch = meta?.valueDomainMismatch;
288
- if (!mismatch || !Array.isArray(mismatch.versions) || mismatch.versions.length === 0) return null;
289
- const n = mismatch.totalRuleCount;
290
- const subject = n === 1 ? "1 rule compares" : `${n} rules compare`;
291
- const lines = [
292
- `\u26A0 ${subject} ${mismatch.factKey} with values the new value domain does not accept. The update was still saved.`
293
- ];
294
- for (const version of mismatch.versions) {
295
- lines.push(...versionLines(mismatch.factKey, version));
296
- }
297
- return lines.join("\n");
298
- }
299
- function versionLines(factKey, version) {
300
- const draft = version.versionStatus === "DRAFT";
301
- const lines = [
302
- ` ${quoted(version.groupName)} v${version.versionNo}, ${draft ? "draft" : "deployed"} (groupId ${version.groupId}, versionId ${version.versionId})`,
303
- draft ? " Dry runs of this draft already reject calls that send these values (P-052), and live calls will too once it is published and deployed. Saving one of these rules again is refused (P-051) until each listed condition is edited or removed; first check whether each one is still needed." : " Calls to it are checked against the fact definitions captured when it was published, so this change does not affect them. A version cloned from it carries these rules; fix them in the clone before publishing it."
304
- ];
305
- for (const rule of version.rules) {
306
- const conditions = rule.conditions.map(
307
- (c) => `${factKey} ${c.operator} ${escapeUnsafe(stringifyJson(c.value) ?? String(c.value))}`
308
- ).join("; ");
309
- lines.push(` \u2022 ${quoted(rule.ruleName)} (ruleId ${rule.ruleId}): ${conditions}`);
310
- }
311
- const hidden = version.ruleCount - version.rules.length;
312
- if (hidden > 0) {
313
- lines.push(` \u2026 and ${hidden} more rule${hidden === 1 ? "" : "s"} in this version.`);
314
- }
315
- return lines;
316
- }
317
- function quoted(name) {
318
- return escapeUnsafe(stringifyJson(String(name)) ?? '""');
319
- }
320
- var UNSAFE = /[\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
321
- function escapeUnsafe(json) {
322
- return json.replace(UNSAFE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
323
- }
324
-
325
- // src/mcp/tools/_shared.ts
326
- function paginationParams(page, size) {
327
- const params = {};
328
- if (page !== void 0) params.page = String(page);
329
- if (size !== void 0) params.size = String(size);
330
- return params;
331
- }
332
- function formatUnregisteredFactWarning(meta) {
333
- const facts = meta?.unregisteredFacts ?? [];
334
- if (facts.length === 0) return null;
335
- const lines = facts.map((f) => {
336
- if (f.inferredType) {
337
- return ` \u2022 ${f.key} (${f.inferredType}) \u2014 register: lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: "${f.inferredType}" })`;
338
- }
339
- const choices = f.candidateTypes?.join(" | ") ?? "STRING | NUMBER | BOOLEAN";
340
- return ` \u2022 ${f.key} (ambiguous) \u2014 pick a type (${choices}), then lexq_facts_create({ key: "${f.key}", name: "${f.suggestedName}", type: <chosen> })`;
341
- });
342
- return [
343
- `\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.`,
344
- ...lines,
345
- `Register them now so the rule validates as intended, or call lexq_facts_unregistered to review the version's full list.`
346
- ].join("\n");
347
- }
348
-
349
381
  // src/lib/dates.ts
350
382
  var ORG_DAY = "yyyy-MM-dd, a whole calendar day in the organization time zone (tenantTimezone in whoami), not UTC";
351
383
  var START_DAY = `Start date, ${ORG_DAY}. Inclusive.`;
@@ -360,7 +392,7 @@ function registerVersionTools(server, callApi) {
360
392
  "lexq_versions_list",
361
393
  {
362
394
  title: "List Policy Versions",
363
- annotations: { readOnlyHint: true },
395
+ annotations: READ_ONLY,
364
396
  description: "List all versions of a policy group.",
365
397
  inputSchema: z3.object({
366
398
  groupId: z3.string().uuid().describe("Policy group ID"),
@@ -376,7 +408,7 @@ function registerVersionTools(server, callApi) {
376
408
  "lexq_versions_get",
377
409
  {
378
410
  title: "Get Policy Version",
379
- annotations: { readOnlyHint: true },
411
+ annotations: READ_ONLY,
380
412
  description: "Get a single version by ID, including its rules and fact requirements.",
381
413
  inputSchema: z3.object({
382
414
  groupId: z3.string().uuid().describe("Policy group ID"),
@@ -389,7 +421,7 @@ function registerVersionTools(server, callApi) {
389
421
  "lexq_versions_create",
390
422
  {
391
423
  title: "Create Policy Version",
392
- annotations: { readOnlyHint: false, destructiveHint: false },
424
+ annotations: ADDITIVE,
393
425
  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.`,
394
426
  inputSchema: z3.object({
395
427
  groupId: z3.string().uuid().describe("Policy group ID"),
@@ -404,7 +436,7 @@ function registerVersionTools(server, callApi) {
404
436
  "lexq_versions_update",
405
437
  {
406
438
  title: "Update Policy Version",
407
- annotations: { readOnlyHint: false, destructiveHint: true },
439
+ annotations: DESTRUCTIVE,
408
440
  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.",
409
441
  inputSchema: z3.object({
410
442
  groupId: z3.string().uuid().describe("Policy group ID"),
@@ -412,7 +444,9 @@ function registerVersionTools(server, callApi) {
412
444
  commitMessage: z3.string().optional().describe("New commit message"),
413
445
  effectiveFrom: z3.string().optional().describe(`Effective start, ${INSTANT} Omit it to keep the current start.`),
414
446
  effectiveTo: z3.string().optional().describe(`Effective end, ${INSTANT} Omit it to keep the current end.`),
415
- clearEffectiveFrom: z3.boolean().optional().describe("Set to true to remove the effective start. Do not send effectiveFrom with it."),
447
+ clearEffectiveFrom: z3.boolean().optional().describe(
448
+ "Set to true to remove the effective start. Do not send effectiveFrom with it."
449
+ ),
416
450
  clearEffectiveTo: z3.boolean().optional().describe("Set to true to remove the effective end. Do not send effectiveTo with it.")
417
451
  })
418
452
  },
@@ -422,7 +456,7 @@ function registerVersionTools(server, callApi) {
422
456
  "lexq_versions_delete",
423
457
  {
424
458
  title: "Delete Policy Version",
425
- annotations: { readOnlyHint: false, destructiveHint: true },
459
+ annotations: DESTRUCTIVE,
426
460
  description: "Delete a DRAFT version. Only DRAFT versions can be deleted.",
427
461
  inputSchema: z3.object({
428
462
  groupId: z3.string().uuid().describe("Policy group ID"),
@@ -435,7 +469,7 @@ function registerVersionTools(server, callApi) {
435
469
  "lexq_versions_clone",
436
470
  {
437
471
  title: "Clone Policy Version",
438
- annotations: { readOnlyHint: false, destructiveHint: false },
472
+ annotations: ADDITIVE,
439
473
  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.`,
440
474
  inputSchema: z3.object({
441
475
  groupId: z3.string().uuid().describe("Policy group ID"),
@@ -459,7 +493,7 @@ function registerRuleTools(server, callApi) {
459
493
  "lexq_rules_list",
460
494
  {
461
495
  title: "List Rules",
462
- annotations: { readOnlyHint: true },
496
+ annotations: READ_ONLY,
463
497
  description: "List all rules in a version (priority ASC). Returns summary with conditionSummary and actionSummary.",
464
498
  inputSchema: z4.object({
465
499
  groupId: z4.string().uuid().describe("Policy group ID"),
@@ -472,7 +506,7 @@ function registerRuleTools(server, callApi) {
472
506
  "lexq_rules_get",
473
507
  {
474
508
  title: "Get Rule Detail",
475
- annotations: { readOnlyHint: true },
509
+ annotations: READ_ONLY,
476
510
  description: "Get full rule detail including condition tree and action definitions.",
477
511
  inputSchema: z4.object({
478
512
  groupId: z4.string().uuid().describe("Policy group ID"),
@@ -486,87 +520,39 @@ function registerRuleTools(server, callApi) {
486
520
  "lexq_rules_create",
487
521
  {
488
522
  title: "Create Rule",
489
- annotations: { readOnlyHint: false, destructiveHint: false },
523
+ annotations: ADDITIVE,
490
524
  description: dedent`
491
- 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.
525
+ Create a rule in a DRAFT version. It goes last in priority order; lexq_rules_reorder changes the order.
492
526
 
493
- Before creating rules with new fact keys, call lexq_facts_list to check existing facts.
494
- If a required key is missing, ask the user to confirm the type, isRequired, and description
495
- before calling lexq_facts_create — registering facts enables type validation, Console UI
496
- autocomplete, and the dry-run requirements analyzer.
497
-
498
- An action's targetVar (SET_FACT, MUTATE_FACT) must be a registered fact. Saving or
499
- publishing a rule whose targetVar is not registered fails with ACT-032, so register it
500
- with lexq_facts_create first. Keys a rule only reads (condition fields, refVar) may stay
501
- unregistered.
502
-
503
- After saving, lexq_facts_unregistered lists any keys this version references but has not
504
- defined (non-blocking, version-wide) — use it to decide what to register.
527
+ An action's targetVar must be a registered fact (ACT-032 otherwise). Keys a rule only reads (condition fields, refVar) may stay unregistered.
505
528
 
506
529
  Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: ${oneOf(LogicalOperator)}, children: [...] }
507
530
  Value types: ${ValueType.join(", ")}
508
- Limits: a condition tree may hold at most ${MAX_CONDITIONS_PER_RULE} conditions (SINGLE nodes; GROUP nodes do not count)
509
- and nest at most ${MAX_CONDITION_DEPTH} levels below the root. A version holds at most ${MAX_RULES_PER_VERSION} rules.
531
+ 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.
510
532
 
511
- Operators are constrained by the LEFT fact's type (from lexq_facts_list). Using one outside
512
- its type is rejected by the server — check the fact type before choosing an operator.
513
- - STRING fact: EQUALS, NOT_EQUALS, CONTAINS, IN, NOT_IN
514
- - NUMBER fact: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
515
- - BOOLEAN fact: EQUALS, NOT_EQUALS
516
- - LIST_* fact: HAS_ANY, HAS_ALL, HAS_NONE (only these)
517
-
518
- HAS_* query list-typed facts. Value is always an array whose element type matches the fact:
519
- - HAS_ANY: fact has at least one of the given values
520
- - HAS_ALL: fact has all of the given values
521
- - HAS_NONE: fact has none of the given values
533
+ Operators that work on each fact type:
534
+ - STRING: EQUALS, NOT_EQUALS, CONTAINS (substring), IN, NOT_IN
535
+ - NUMBER: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
536
+ - BOOLEAN: EQUALS, NOT_EQUALS
537
+ - LIST_STRING, LIST_NUMBER: HAS_ANY, HAS_ALL, HAS_NONE
538
+ 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.
522
539
  Example: { "type": "SINGLE", "field": "userTags", "operator": "HAS_ANY", "value": ["VIP","GOLD"], "valueType": "LIST_STRING" }
523
540
 
524
- Do NOT use CONTAINS on a list fact — CONTAINS is substring match on STRING facts only.
525
- IN is the mirror of HAS_*: IN takes a scalar fact with a list value; HAS_* takes lists on both sides.
526
-
527
541
  Actions: [{ type, parameters }]
542
+ - MUTATE_FACT: { targetVar, operator: ${oneOf(MutateOperator)}, method: ${oneOf(CalculationMethod)}, operand: number, refVar?, rounding? }
543
+ 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.
544
+ AMOUNT PERCENTAGE (base = refVar, or targetVar when omitted)
545
+ ASSIGN targetVar = operand targetVar = base × operand/100
546
+ ADD targetVar += operand targetVar += base × operand/100
547
+ SUB targetVar -= operand targetVar -= base × operand/100
548
+ MUL targetVar *= operand targetVar *= (operand/100 + 1)
549
+ DIV targetVar /= operand invalid
550
+ refVar is allowed only with PERCENTAGE and ASSIGN, ADD or SUB. DIV with AMOUNT needs a nonzero operand.
551
+ rounding: { scale: integer (0..${MAX_ROUNDING_SCALE}), mode?: ${oneOf(RoundingPolicy)} }. mode defaults to HALF_UP. Without rounding, the result keeps full precision.
552
+ - SET_FACT: { targetVar, value: string|number|boolean }. The request does not have to send targetVar.
553
+ - 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).
528
554
 
529
- Action parameter schemas:
530
- - MUTATE_FACT: { targetVar: string, operator: ${oneOf(MutateOperator)}, method: ${oneOf(CalculationMethod)}, operand: number, refVar?: string, rounding?: RoundingOption }
531
- targetVar is the fact this action reads and writes. It must be a registered fact
532
- (ACT-032 otherwise), and the request must send it (P-015 otherwise). Its value must be
533
- a number when the action runs.
534
- A missing required fact throws (no 0 default).
535
- operand is the arithmetic operand; the unit is dictated by method (percent when
536
- PERCENTAGE, absolute amount when AMOUNT). Ranges are not constrained — negative values
537
- and >100 percentages are valid (refunds, surcharges).
538
- refVar is the base for percentage calculation and is OPTIONAL — omit it to use targetVar
539
- itself. It is only meaningful in PERCENTAGE × {ASSIGN, ADD, SUB}; specifying it in any
540
- other cell is an error. Use it when the base differs from the target, e.g.
541
- "points += orderTotal × 5%" → { targetVar: "points", refVar: "orderTotal",
542
- operator: "ADD", method: "PERCENTAGE", operand: 5 }.
543
- operator × method matrix:
544
- ASSIGN targetVar = operand | targetVar = refVar × operand/100
545
- ADD targetVar += operand | targetVar += refVar × operand/100
546
- SUB targetVar -= operand | targetVar -= refVar × operand/100
547
- MUL targetVar *= operand | targetVar *= (operand/100 + 1)
548
- DIV targetVar /= operand | invalid
549
- Constraints: DIV + PERCENTAGE is invalid (use MUL with the inverse). DIV + AMOUNT
550
- requires operand !== 0.
551
- - SET_FACT: { targetVar: string, value: string|number|boolean } targetVar must be a
552
- registered fact (ACT-032 otherwise). The request does not have to send it: SET_FACT is
553
- the only action whose targetVar may be absent from the input facts. MUTATE_FACT requires
554
- its target to be present in the input facts.
555
- - BLOCK: { reason?: string } Records a rejection decision. It does NOT halt rule execution —
556
- subsequent actions and subsequent winning rules still run. Enforcement is the caller's
557
- responsibility; the decision surfaces as isBlocked (true) and blockReason (reason, or
558
- "Policy Violation" when omitted).
559
- Only BLOCK writes those two keys: saving or publishing a rule whose SET_FACT or
560
- MUTATE_FACT targets either one fails with ACT-033.
561
-
562
- Evaluation order: every rule's condition is checked against the input facts before any
563
- action runs, so a condition never sees a value an action writes in the same request. Then
564
- the actions of the winning rules run in priority order. Each action works on the values
565
- the actions before it left, in its own rule or an earlier one. To test a value an action
566
- writes, send that value as an input fact in a second request. If a rule should run
567
- whenever another one does, put its actions in that rule instead.
568
-
569
- RoundingOption (optional, MUTATE_FACT only): { scale: integer (0..${MAX_ROUNDING_SCALE}), mode?: ${oneOf(RoundingPolicy)} } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
555
+ 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.
570
556
  `,
571
557
  inputSchema: z4.object({
572
558
  groupId: z4.string().uuid().describe("Policy group ID"),
@@ -585,7 +571,7 @@ function registerRuleTools(server, callApi) {
585
571
  "lexq_rules_update",
586
572
  {
587
573
  title: "Update Rule",
588
- annotations: { readOnlyHint: false, destructiveHint: true },
574
+ annotations: DESTRUCTIVE,
589
575
  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.`,
590
576
  inputSchema: z4.object({
591
577
  groupId: z4.string().uuid().describe("Policy group ID"),
@@ -607,28 +593,21 @@ function registerRuleTools(server, callApi) {
607
593
  "lexq_rules_delete",
608
594
  {
609
595
  title: "Delete Rule",
610
- annotations: { readOnlyHint: false, destructiveHint: true },
596
+ annotations: DESTRUCTIVE,
611
597
  description: "Delete a rule from a DRAFT version.",
612
598
  inputSchema: z4.object({
613
599
  groupId: z4.string().uuid().describe("Policy group ID"),
614
600
  versionId: z4.string().uuid().describe("Version ID"),
615
- ruleId: z4.string().uuid().describe("Rule ID"),
616
- force: z4.boolean().default(false).describe("Skip confirmation (for the last rule in a version)")
601
+ ruleId: z4.string().uuid().describe("Rule ID")
617
602
  })
618
603
  },
619
- async ({ groupId, versionId, ruleId, force }) => {
620
- const params = {};
621
- if (force) params.force = "true";
622
- return callApi("DELETE", `policy-groups/${groupId}/versions/${versionId}/rules/${ruleId}`, {
623
- params
624
- });
625
- }
604
+ async ({ groupId, versionId, ruleId }) => callApi("DELETE", `policy-groups/${groupId}/versions/${versionId}/rules/${ruleId}`)
626
605
  );
627
606
  server.registerTool(
628
607
  "lexq_rules_reorder",
629
608
  {
630
609
  title: "Reorder Rules",
631
- annotations: { readOnlyHint: false, destructiveHint: true },
610
+ annotations: DESTRUCTIVE,
632
611
  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).",
633
612
  inputSchema: z4.object({
634
613
  groupId: z4.string().uuid().describe("Policy group ID"),
@@ -650,7 +629,7 @@ function registerRuleTools(server, callApi) {
650
629
  "lexq_rules_toggle",
651
630
  {
652
631
  title: "Toggle Rule",
653
- annotations: { readOnlyHint: false, destructiveHint: true },
632
+ annotations: DESTRUCTIVE,
654
633
  description: "Enable or disable a rule without deleting it.",
655
634
  inputSchema: z4.object({
656
635
  groupId: z4.string().uuid().describe("Policy group ID"),
@@ -677,13 +656,14 @@ var EXPORT_FORMATS = ExportFormat.map((name) => name.toLowerCase()).sort();
677
656
 
678
657
  // src/mcp/tools/facts.ts
679
658
  var VALUE_TYPES = ValueType;
659
+ var IS_REQUIRED_NOTE = "isRequired is stored on the definition only; the engine does not read it.";
680
660
  function registerFactTools(server, callApi) {
681
661
  server.registerTool(
682
662
  "lexq_facts_list",
683
663
  {
684
664
  title: "List Fact Definitions",
685
- annotations: { readOnlyHint: true },
686
- description: "List all fact definitions (input variable schema). Shows key, type, required, and PII status. Always check this before creating rules.",
665
+ annotations: READ_ONLY,
666
+ description: `List all fact definitions (input variable schema): key, name, type, description, PII and system flags, and value domain. ${IS_REQUIRED_NOTE}`,
687
667
  inputSchema: z5.object({
688
668
  page: z5.number().int().min(0).default(0).describe("Page number"),
689
669
  size: z5.number().int().min(1).max(100).default(20).describe("Page size"),
@@ -700,14 +680,14 @@ function registerFactTools(server, callApi) {
700
680
  "lexq_facts_create",
701
681
  {
702
682
  title: "Create Fact Definition",
703
- annotations: { readOnlyHint: false, destructiveHint: false },
683
+ annotations: ADDITIVE,
704
684
  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(", ")}.`,
705
685
  inputSchema: z5.object({
706
686
  key: z5.string().regex(FACT_KEY_PATTERN).describe("Variable key. Any casing; must start with a letter."),
707
687
  name: z5.string().describe("Display name"),
708
688
  type: z5.enum(VALUE_TYPES).describe("Value type"),
709
689
  description: z5.string().optional().describe("Description"),
710
- isRequired: z5.boolean().default(false).describe("Whether this fact is required for rule evaluation"),
690
+ isRequired: z5.boolean().default(false).describe(IS_REQUIRED_NOTE),
711
691
  isPii: z5.boolean().default(false).describe(
712
692
  "Mark as PII \u2014 masked on every read surface, revealable only in the console (audited)"
713
693
  ),
@@ -728,7 +708,7 @@ function registerFactTools(server, callApi) {
728
708
  "lexq_facts_update",
729
709
  {
730
710
  title: "Update Fact Definition",
731
- annotations: { readOnlyHint: false, destructiveHint: true },
711
+ annotations: DESTRUCTIVE,
732
712
  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).",
733
713
  inputSchema: z5.object({
734
714
  factId: z5.string().uuid().describe("Fact definition ID"),
@@ -737,7 +717,7 @@ function registerFactTools(server, callApi) {
737
717
  type: z5.enum(VALUE_TYPES).optional().describe(
738
718
  "Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule outside a deleted group references the fact."
739
719
  ),
740
- isRequired: z5.boolean().optional().describe("Required flag"),
720
+ isRequired: z5.boolean().optional().describe(IS_REQUIRED_NOTE),
741
721
  isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
742
722
  valueDomain: z5.object({
743
723
  allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
@@ -756,7 +736,7 @@ function registerFactTools(server, callApi) {
756
736
  "lexq_facts_delete",
757
737
  {
758
738
  title: "Delete Fact Definition",
759
- annotations: { readOnlyHint: false, destructiveHint: true },
739
+ annotations: DESTRUCTIVE,
760
740
  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.",
761
741
  inputSchema: z5.object({
762
742
  factId: z5.string().uuid().describe("Fact definition ID")
@@ -768,7 +748,7 @@ function registerFactTools(server, callApi) {
768
748
  "lexq_facts_action_metadata",
769
749
  {
770
750
  title: "Get Action Runtime Fact Metadata",
771
- annotations: { readOnlyHint: true },
751
+ annotations: READ_ONLY,
772
752
  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.",
773
753
  inputSchema: z5.object({})
774
754
  },
@@ -778,8 +758,8 @@ function registerFactTools(server, callApi) {
778
758
  "lexq_facts_unregistered",
779
759
  {
780
760
  title: "List Unregistered Facts",
781
- annotations: { readOnlyHint: true },
782
- 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.",
761
+ annotations: READ_ONLY,
762
+ 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).",
783
763
  inputSchema: z5.object({
784
764
  groupId: z5.string().uuid().describe("Policy group ID"),
785
765
  versionId: z5.string().uuid().describe("Version ID")
@@ -791,16 +771,20 @@ function registerFactTools(server, callApi) {
791
771
  "lexq_facts_export",
792
772
  {
793
773
  title: "Export Fact Catalog",
794
- annotations: { readOnlyHint: true },
795
- 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.",
774
+ annotations: READ_ONLY,
775
+ 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}`,
796
776
  inputSchema: z5.object({
797
777
  format: z5.enum(EXPORT_FORMATS).default("csv").describe("Export format"),
798
778
  keyword: z5.string().optional().describe("Filter by key or name")
799
779
  })
800
780
  },
801
- async ({ format, keyword }) => callApi("GET", "schema/facts/export", {
802
- params: keyword ? { format, keyword } : { format }
803
- })
781
+ async ({ format, keyword }) => limitExport(
782
+ await callApi("GET", "schema/facts/export", {
783
+ params: keyword ? { format, keyword } : { format }
784
+ }),
785
+ `lexq facts export --as ${format} --output <file>`,
786
+ "Pass keyword to export fewer facts."
787
+ )
804
788
  );
805
789
  }
806
790
 
@@ -812,7 +796,7 @@ function registerDeployTools(server, callApi) {
812
796
  "lexq_deploy_publish",
813
797
  {
814
798
  title: "Publish Version",
815
- annotations: { readOnlyHint: false, destructiveHint: false },
799
+ annotations: DESTRUCTIVE,
816
800
  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.`,
817
801
  inputSchema: z6.object({
818
802
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -826,7 +810,7 @@ function registerDeployTools(server, callApi) {
826
810
  "lexq_deploy_live",
827
811
  {
828
812
  title: "Deploy to Live",
829
- annotations: { readOnlyHint: false, destructiveHint: true },
813
+ annotations: DESTRUCTIVE,
830
814
  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.",
831
815
  inputSchema: z6.object({
832
816
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -842,7 +826,7 @@ function registerDeployTools(server, callApi) {
842
826
  "lexq_deploy_rollback",
843
827
  {
844
828
  title: "Rollback Deployment",
845
- annotations: { readOnlyHint: false, destructiveHint: true },
829
+ annotations: DESTRUCTIVE,
846
830
  description: dedent2`
847
831
  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).
848
832
 
@@ -863,7 +847,7 @@ function registerDeployTools(server, callApi) {
863
847
  "lexq_deploy_undeploy",
864
848
  {
865
849
  title: "Undeploy",
866
- annotations: { readOnlyHint: false, destructiveHint: true },
850
+ annotations: DESTRUCTIVE,
867
851
  description: "Remove the live version from traffic. The version stays ACTIVE but no longer serves requests.",
868
852
  inputSchema: z6.object({
869
853
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -878,7 +862,7 @@ function registerDeployTools(server, callApi) {
878
862
  "lexq_deploy_schedule",
879
863
  {
880
864
  title: "Schedule Deployment",
881
- annotations: { readOnlyHint: false, destructiveHint: false },
865
+ annotations: DESTRUCTIVE,
882
866
  description: "Schedule an ACTIVE version with a future effective start date to auto-deploy at that time (Scheduled Deployment). The time is the version's effectiveFrom, set on the DRAFT before publishing; this call takes no time of its own. One pending schedule per group; manual deploy/rollback/undeploy, starting an A/B test, or archiving the group cancels it. The snapshot hash is sealed at scheduling and re-verified at execution (fail-closed).",
883
867
  inputSchema: z6.object({
884
868
  groupId: z6.string().uuid().describe("Policy group ID"),
@@ -894,7 +878,7 @@ function registerDeployTools(server, callApi) {
894
878
  "lexq_deploy_unschedule",
895
879
  {
896
880
  title: "Cancel Scheduled Deployment",
897
- annotations: { readOnlyHint: false, destructiveHint: true },
881
+ annotations: DESTRUCTIVE,
898
882
  description: "Cancel the pending scheduled deployment for a group. The version itself is not affected. Fails with P-039 if no pending schedule exists.",
899
883
  inputSchema: z6.object({
900
884
  groupId: z6.string().uuid().describe("Policy group ID")
@@ -906,7 +890,7 @@ function registerDeployTools(server, callApi) {
906
890
  "lexq_deploy_schedules",
907
891
  {
908
892
  title: "List Scheduled Deployments",
909
- annotations: { readOnlyHint: true },
893
+ annotations: READ_ONLY,
910
894
  description: `List scheduled deployments across all groups (all statuses: ${DeploymentScheduleStatus.join(", ")}), newest first.`,
911
895
  inputSchema: z6.object({
912
896
  page: z6.number().int().min(0).default(0).describe("Page number"),
@@ -919,7 +903,7 @@ function registerDeployTools(server, callApi) {
919
903
  "lexq_deploy_history",
920
904
  {
921
905
  title: "Deployment History",
922
- annotations: { readOnlyHint: true },
906
+ annotations: READ_ONLY,
923
907
  description: "List deployment history across all groups.",
924
908
  inputSchema: z6.object({
925
909
  page: z6.number().int().min(0).default(0).describe("Page number"),
@@ -943,7 +927,7 @@ function registerDeployTools(server, callApi) {
943
927
  "lexq_deploy_detail",
944
928
  {
945
929
  title: "Deployment Detail",
946
- annotations: { readOnlyHint: true },
930
+ annotations: READ_ONLY,
947
931
  description: "Get detailed info about a specific deployment including snapshot hash and integrity check.",
948
932
  inputSchema: z6.object({
949
933
  deploymentId: z6.string().uuid().describe("Deployment ID")
@@ -955,7 +939,7 @@ function registerDeployTools(server, callApi) {
955
939
  "lexq_deploy_overview",
956
940
  {
957
941
  title: "Deployment Overview",
958
- annotations: { readOnlyHint: true },
942
+ annotations: READ_ONLY,
959
943
  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.",
960
944
  inputSchema: z6.object({})
961
945
  },
@@ -965,7 +949,7 @@ function registerDeployTools(server, callApi) {
965
949
  "lexq_deploy_deployable",
966
950
  {
967
951
  title: "List Deployable Versions",
968
- annotations: { readOnlyHint: true },
952
+ annotations: READ_ONLY,
969
953
  description: "List ACTIVE (published) versions that can be deployed for a group. Use this to find which versions are available before calling deploy live.",
970
954
  inputSchema: z6.object({
971
955
  groupId: z6.string().uuid().describe("Policy group ID")
@@ -977,7 +961,7 @@ function registerDeployTools(server, callApi) {
977
961
  "lexq_deploy_diff",
978
962
  {
979
963
  title: "Deployment Diff",
980
- annotations: { readOnlyHint: true },
964
+ annotations: READ_ONLY,
981
965
  description: "Compare rule snapshots between two versions. Shows added, removed, and modified rules. Useful for reviewing changes before deploying a new version.",
982
966
  inputSchema: z6.object({
983
967
  baseVersionId: z6.string().uuid().describe("Base version ID (typically the current live)"),
@@ -1005,7 +989,7 @@ function registerAnalyticsTools(server, callApi) {
1005
989
  "lexq_dry_run",
1006
990
  {
1007
991
  title: "Dry Run",
1008
- annotations: { readOnlyHint: true },
992
+ annotations: READ_ONLY,
1009
993
  description: dedent3`
1010
994
  Execute a single dry run against a version. Tests how rules evaluate given input facts without side effects.
1011
995
 
@@ -1016,8 +1000,7 @@ function registerAnalyticsTools(server, callApi) {
1016
1000
  executionTraces — per-rule match status
1017
1001
  decisionTraces — per-rule decision (SELECTED / NO_MATCH / BLOCKED / etc.)
1018
1002
 
1019
- Example input: { "facts": { "paymentAmount": 100000, "customerTier": "VIP" } }
1020
- Always dry-run before publishing to validate rule behavior.`,
1003
+ Example input: { "facts": { "paymentAmount": 100000, "customerTier": "VIP" } }`,
1021
1004
  inputSchema: z7.object({
1022
1005
  versionId: z7.string().uuid().describe("Policy version ID to test against"),
1023
1006
  facts: z7.string().describe('JSON string of facts object, e.g. {"paymentAmount":100000}'),
@@ -1035,7 +1018,7 @@ function registerAnalyticsTools(server, callApi) {
1035
1018
  "lexq_dry_run_compare",
1036
1019
  {
1037
1020
  title: "Dry Run Compare",
1038
- annotations: { readOnlyHint: true },
1021
+ annotations: READ_ONLY,
1039
1022
  description: dedent3`
1040
1023
  Compare dry run results between two versions using the same input facts. Useful for validating changes.
1041
1024
 
@@ -1060,7 +1043,7 @@ function registerAnalyticsTools(server, callApi) {
1060
1043
  "lexq_requirements",
1061
1044
  {
1062
1045
  title: "Analyze Requirements",
1063
- annotations: { readOnlyHint: true },
1046
+ annotations: READ_ONLY,
1064
1047
  description: "Analyze which input facts a version requires. Returns required keys, types, and an example request body.",
1065
1048
  inputSchema: z7.object({
1066
1049
  groupId: z7.string().uuid().describe("Policy group ID"),
@@ -1073,10 +1056,13 @@ function registerAnalyticsTools(server, callApi) {
1073
1056
  "lexq_simulation_start",
1074
1057
  {
1075
1058
  title: "Start Simulation",
1076
- annotations: { readOnlyHint: false, destructiveHint: false },
1059
+ annotations: DESTRUCTIVE,
1077
1060
  description: dedent3`
1078
1061
  Start an Impact Simulation against historical, uploaded, or inline data.
1079
1062
 
1063
+ Starting checks the plan's simulation usage limit. The records a simulation processes
1064
+ count toward that usage when it completes or is canceled.
1065
+
1080
1066
  dataset.type and dataset.source are BOTH required, and must be paired:
1081
1067
  HISTORICAL → source EXECUTION_LOGS, with dataset.from / dataset.to (yyyy-MM-dd, whole days
1082
1068
  in the organization time zone, both inclusive)
@@ -1117,7 +1103,7 @@ function registerAnalyticsTools(server, callApi) {
1117
1103
  "lexq_simulation_status",
1118
1104
  {
1119
1105
  title: "Simulation Status",
1120
- annotations: { readOnlyHint: true },
1106
+ annotations: READ_ONLY,
1121
1107
  description: "Get simulation status and results. Poll until status is COMPLETED or FAILED.",
1122
1108
  inputSchema: z7.object({
1123
1109
  simulationId: z7.string().uuid().describe("Simulation ID")
@@ -1129,7 +1115,7 @@ function registerAnalyticsTools(server, callApi) {
1129
1115
  "lexq_simulation_list",
1130
1116
  {
1131
1117
  title: "List Simulations",
1132
- annotations: { readOnlyHint: true },
1118
+ annotations: READ_ONLY,
1133
1119
  description: "List simulation history with optional filters.",
1134
1120
  inputSchema: z7.object({
1135
1121
  page: z7.number().int().min(0).default(0).describe("Page number"),
@@ -1151,7 +1137,7 @@ function registerAnalyticsTools(server, callApi) {
1151
1137
  "lexq_simulation_cancel",
1152
1138
  {
1153
1139
  title: "Cancel Simulation",
1154
- annotations: { readOnlyHint: false, destructiveHint: true },
1140
+ annotations: DESTRUCTIVE,
1155
1141
  description: "Cancel a running or pending simulation.",
1156
1142
  inputSchema: z7.object({
1157
1143
  simulationId: z7.string().uuid().describe("Simulation ID")
@@ -1163,22 +1149,25 @@ function registerAnalyticsTools(server, callApi) {
1163
1149
  "lexq_simulation_export",
1164
1150
  {
1165
1151
  title: "Export Simulation",
1166
- annotations: { readOnlyHint: true },
1167
- description: "Export simulation results as JSON or CSV. Returns the raw data.",
1152
+ annotations: READ_ONLY,
1153
+ description: `Export simulation results as JSON or CSV. Returns the raw data. ${EXPORT_LIMIT_NOTE}`,
1168
1154
  inputSchema: z7.object({
1169
1155
  simulationId: z7.string().uuid().describe("Simulation ID"),
1170
1156
  format: z7.enum(EXPORT_FORMATS).default("json").describe("Export format")
1171
1157
  })
1172
1158
  },
1173
- async ({ simulationId, format }) => callApi("GET", `analytics/simulations/${simulationId}/export`, {
1174
- params: { format }
1175
- })
1159
+ async ({ simulationId, format }) => limitExport(
1160
+ await callApi("GET", `analytics/simulations/${simulationId}/export`, {
1161
+ params: { format }
1162
+ }),
1163
+ `lexq analytics simulation export --id ${simulationId} --as ${format} --output <file>`
1164
+ )
1176
1165
  );
1177
1166
  server.registerTool(
1178
1167
  "lexq_dataset_upload",
1179
1168
  {
1180
1169
  title: "Upload Dataset",
1181
- annotations: { readOnlyHint: false, destructiveHint: false },
1170
+ annotations: ADDITIVE,
1182
1171
  description: dedent3`
1183
1172
  Upload inline CSV or JSON content as a simulation dataset.
1184
1173
  The content is uploaded to S3 and a path is returned in the "path" field.
@@ -1221,7 +1210,7 @@ function registerAnalyticsTools(server, callApi) {
1221
1210
  "lexq_dataset_template",
1222
1211
  {
1223
1212
  title: "Download Dataset Template",
1224
- annotations: { readOnlyHint: true },
1213
+ annotations: READ_ONLY,
1225
1214
  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.",
1226
1215
  inputSchema: z7.object({
1227
1216
  groupId: z7.string().uuid().describe("Policy group ID"),
@@ -1260,7 +1249,7 @@ function registerProfileTools(server, callApi) {
1260
1249
  "lexq_profile_overview",
1261
1250
  {
1262
1251
  title: "Group Latency Profile",
1263
- annotations: { readOnlyHint: true },
1252
+ annotations: READ_ONLY,
1264
1253
  description: "Per-rule latency profile of a policy group over a time window: group TOTAL distribution split by cache state (HIT = compiled ruleset cache hit, MISS = deep-load + compile), a per-rule CONDITION/ACTION percentile table, and slow-rule flags. " + RELATIVE_THRESHOLD + ` Every percentile is accompanied by its sample count n; a percentile is withheld (null) unless n\xD7(1\u2212q) \u2265 ${TAIL_MIN_OBS} (p50 needs n \u2265 ${PERCENTILE_MIN_N.p50}, p95 n \u2265 ${PERCENTILE_MIN_N.p95}, p99 n \u2265 ${PERCENTILE_MIN_N.p99} \u2014 display gate, separate from the n \u2265 ${MIN_SAMPLES} judgment gate). Baselines report INSUFFICIENT_COHORT when fewer than ${MIN_COHORT_SIZE} rules qualify. Rule detail comes from a deterministic ${PROFILE_SAMPLE_PERMILLE / 10}% sample of calls; TOTAL is recorded for every call. Defaults: last ${PROFILE_DEFAULT_WINDOW_HOURS}h, live version, cacheState HIT.`,
1265
1254
  inputSchema: z8.object({
1266
1255
  groupId: z8.string().uuid().describe("Policy group ID"),
@@ -1280,7 +1269,7 @@ function registerProfileTools(server, callApi) {
1280
1269
  "lexq_profile_rule",
1281
1270
  {
1282
1271
  title: "Rule Latency Detail",
1283
- annotations: { readOnlyHint: true },
1272
+ annotations: READ_ONLY,
1284
1273
  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,
1285
1274
  inputSchema: z8.object({
1286
1275
  groupId: z8.string().uuid().describe("Policy group ID"),
@@ -1305,7 +1294,7 @@ function registerReplayTools(server, callApi) {
1305
1294
  "lexq_replay_decision",
1306
1295
  {
1307
1296
  title: "Replay a Decision",
1308
- annotations: { readOnlyHint: true },
1297
+ annotations: READ_ONLY,
1309
1298
  description: "Re-evaluate a past execution (traceId) against a candidate version and return the decision diff (decisionChanged, effect changes, fired rules) plus a determinism verdict. Synchronous and free of charge (TPS throttle only). A replay sends no webhook, notification, or event: rule actions produce no outward effects.",
1310
1299
  inputSchema: z9.object({
1311
1300
  traceId: z9.string().describe("Trace ID of the past execution to replay"),
@@ -1318,7 +1307,7 @@ function registerReplayTools(server, callApi) {
1318
1307
  "lexq_replay_start",
1319
1308
  {
1320
1309
  title: "Start Window Replay (Blast Radius)",
1321
- annotations: { readOnlyHint: false, destructiveHint: false },
1310
+ annotations: DESTRUCTIVE,
1322
1311
  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.",
1323
1312
  inputSchema: z9.object({
1324
1313
  candidateVersionId: z9.string().uuid().describe("Version to re-evaluate against"),
@@ -1335,7 +1324,7 @@ function registerReplayTools(server, callApi) {
1335
1324
  "lexq_replay_status",
1336
1325
  {
1337
1326
  title: "Get Replay Job Status",
1338
- annotations: { readOnlyHint: true },
1327
+ annotations: READ_ONLY,
1339
1328
  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.",
1340
1329
  inputSchema: z9.object({
1341
1330
  jobId: z9.string().describe("Replay job ID from lexq_replay_start")
@@ -1347,7 +1336,7 @@ function registerReplayTools(server, callApi) {
1347
1336
  "lexq_replay_list",
1348
1337
  {
1349
1338
  title: "List Replay Jobs",
1350
- annotations: { readOnlyHint: true },
1339
+ annotations: READ_ONLY,
1351
1340
  description: "List window replay job history (reverse-chronological). Lightweight items \u2014 use lexq_replay_status for summary and changed samples.",
1352
1341
  inputSchema: z9.object({
1353
1342
  page: z9.number().int().min(0).default(0).describe("Page number"),
@@ -1360,7 +1349,7 @@ function registerReplayTools(server, callApi) {
1360
1349
  "lexq_replay_cancel",
1361
1350
  {
1362
1351
  title: "Cancel Replay Job",
1363
- annotations: { readOnlyHint: false, destructiveHint: true },
1352
+ annotations: DESTRUCTIVE,
1364
1353
  description: "Cooperatively cancel a PENDING or RUNNING window replay job. Other states are rejected. VIEWER role cannot cancel.",
1365
1354
  inputSchema: z9.object({
1366
1355
  jobId: z9.string().describe("Replay job ID")
@@ -1372,14 +1361,17 @@ function registerReplayTools(server, callApi) {
1372
1361
  "lexq_replay_export",
1373
1362
  {
1374
1363
  title: "Export Replay Result",
1375
- annotations: { readOnlyHint: true },
1376
- 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.",
1364
+ annotations: READ_ONLY,
1365
+ description: `Export a COMPLETED window replay job. A running job is rejected \u2014 a partial result reads as the whole thing on the receiving end. The two formats carry different things: CSV holds the effect blast radius, whose columns are fixed; JSON holds the changed samples and action parameters, which nest and whose keys differ per tenant. JSON is not a superset of CSV. Returns the file contents as text. ${EXPORT_LIMIT_NOTE}`,
1377
1366
  inputSchema: z9.object({
1378
1367
  jobId: z9.string().describe("Replay job ID"),
1379
1368
  format: z9.enum(EXPORT_FORMATS).default("json").describe("Export format")
1380
1369
  })
1381
1370
  },
1382
- async ({ jobId, format }) => callApi("GET", `replay/jobs/${jobId}/export`, { params: { format } })
1371
+ async ({ jobId, format }) => limitExport(
1372
+ await callApi("GET", `replay/jobs/${jobId}/export`, { params: { format } }),
1373
+ `lexq replay export --id ${jobId} --as ${format} --output <file>`
1374
+ )
1383
1375
  );
1384
1376
  }
1385
1377
 
@@ -1390,7 +1382,7 @@ function registerHistoryTools(server, callApi) {
1390
1382
  "lexq_history_list",
1391
1383
  {
1392
1384
  title: "List Execution History",
1393
- annotations: { readOnlyHint: true },
1385
+ annotations: READ_ONLY,
1394
1386
  description: "List policy execution history. Shows trace ID, group, version, status, match result, and latency.",
1395
1387
  inputSchema: z10.object({
1396
1388
  page: z10.number().int().min(0).default(0).describe("Page number"),
@@ -1418,7 +1410,7 @@ function registerHistoryTools(server, callApi) {
1418
1410
  "lexq_history_get",
1419
1411
  {
1420
1412
  title: "Get Execution Detail",
1421
- annotations: { readOnlyHint: true },
1413
+ annotations: READ_ONLY,
1422
1414
  description: "Get full execution detail including inputFacts, mutatedFacts, generatedVariables, executionTraces, and decisionTraces.",
1423
1415
  inputSchema: z10.object({
1424
1416
  traceId: z10.string().describe("Trace ID from execution history")
@@ -1430,7 +1422,7 @@ function registerHistoryTools(server, callApi) {
1430
1422
  "lexq_history_stats",
1431
1423
  {
1432
1424
  title: "Execution Statistics",
1433
- annotations: { readOnlyHint: true },
1425
+ annotations: READ_ONLY,
1434
1426
  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.`,
1435
1427
  inputSchema: z10.object({
1436
1428
  groupId: z10.string().uuid().optional().describe("Filter by policy group"),
@@ -1455,7 +1447,7 @@ function registerProvenanceTools(server, callApi) {
1455
1447
  "lexq_provenance_get",
1456
1448
  {
1457
1449
  title: "Get Decision Provenance",
1458
- annotations: { readOnlyHint: true },
1450
+ annotations: READ_ONLY,
1459
1451
  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.",
1460
1452
  inputSchema: z11.object({
1461
1453
  traceId: z11.string().describe("Trace ID of the execution")
@@ -1467,7 +1459,7 @@ function registerProvenanceTools(server, callApi) {
1467
1459
  "lexq_pii_reveals_list",
1468
1460
  {
1469
1461
  title: "List PII Reveal Audits",
1470
- annotations: { readOnlyHint: true },
1462
+ annotations: READ_ONLY,
1471
1463
  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.",
1472
1464
  inputSchema: z11.object({
1473
1465
  page: z11.number().int().min(0).default(0).describe("Page number"),
@@ -1493,24 +1485,13 @@ function registerProvenanceTools(server, callApi) {
1493
1485
 
1494
1486
  // src/mcp/tools/logs.ts
1495
1487
  import { z as z12 } from "zod";
1496
-
1497
- // src/lib/enum-notes.ts
1498
- function withNotes(notes) {
1499
- return Object.entries(notes).map(([value, note]) => note ? `${value} (${note})` : value);
1500
- }
1501
-
1502
- // src/mcp/tools/logs.ts
1503
- var ACTION_NOTES = {
1504
- RESOLVE: "mark as manually fixed",
1505
- IGNORE: "skip intentionally"
1506
- };
1507
1488
  function registerLogTools(server, callApi) {
1508
1489
  server.registerTool(
1509
1490
  "lexq_logs_list",
1510
1491
  {
1511
1492
  title: "List Failure Logs",
1512
- annotations: { readOnlyHint: true },
1513
- 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.`,
1493
+ annotations: READ_ONLY,
1494
+ 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}`,
1514
1495
  inputSchema: z12.object({
1515
1496
  page: z12.number().int().min(0).default(0).describe("Page number"),
1516
1497
  size: z12.number().int().min(1).max(100).default(20).describe("Page size"),
@@ -1535,48 +1516,25 @@ function registerLogTools(server, callApi) {
1535
1516
  "lexq_logs_get",
1536
1517
  {
1537
1518
  title: "Get Failure Log",
1538
- annotations: { readOnlyHint: true },
1539
- description: "Get failure log detail by ID.",
1519
+ annotations: READ_ONLY,
1520
+ description: `Get failure log detail by ID. ${FAILURE_LOG_CONSOLE_NOTE}`,
1540
1521
  inputSchema: z12.object({
1541
1522
  logId: z12.string().uuid().describe("Failure log ID")
1542
1523
  })
1543
1524
  },
1544
1525
  async ({ logId }) => callApi("GET", `failure-logs/${logId}`)
1545
1526
  );
1546
- server.registerTool(
1547
- "lexq_logs_action",
1548
- {
1549
- title: "Process Failure Log",
1550
- annotations: { readOnlyHint: false, destructiveHint: false },
1551
- description: `Process a single failure log: ${withNotes(ACTION_NOTES).join(" or ")}.`,
1552
- inputSchema: z12.object({
1553
- logId: z12.string().uuid().describe("Failure log ID"),
1554
- action: z12.enum(FailureAction).describe("Action to take")
1555
- })
1556
- },
1557
- async ({ logId, action }) => callApi("POST", `failure-logs/${logId}/actions`, {
1558
- params: { action }
1559
- })
1560
- );
1561
- server.registerTool(
1562
- "lexq_logs_bulk_action",
1563
- {
1564
- title: "Bulk Process Failure Logs",
1565
- annotations: { readOnlyHint: false, destructiveHint: false },
1566
- description: "Process multiple failure logs at once. Provide an array of log IDs and the action.",
1567
- inputSchema: z12.object({
1568
- logIds: z12.array(z12.string().uuid()).describe("Array of failure log IDs"),
1569
- action: z12.enum(FailureAction).describe("Action to apply to all logs")
1570
- })
1571
- },
1572
- async ({ logIds, action }) => callApi("POST", "failure-logs/bulk-actions", {
1573
- body: { logIds, action }
1574
- })
1575
- );
1576
1527
  }
1577
1528
 
1578
1529
  // src/mcp/tools/webhook-subscriptions.ts
1579
1530
  import { z as z13 } from "zod";
1531
+
1532
+ // src/lib/enum-notes.ts
1533
+ function withNotes(notes) {
1534
+ return Object.entries(notes).map(([value, note]) => note ? `${value} (${note})` : value);
1535
+ }
1536
+
1537
+ // src/mcp/tools/webhook-subscriptions.ts
1580
1538
  var FORMAT_NOTES = {
1581
1539
  GENERIC: "full JSON",
1582
1540
  SLACK: '{"text": "..."}'
@@ -1586,7 +1544,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
1586
1544
  "lexq_webhook_subscriptions_list",
1587
1545
  {
1588
1546
  title: "List Webhook Subscriptions",
1589
- annotations: { readOnlyHint: true },
1547
+ annotations: READ_ONLY,
1590
1548
  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.",
1591
1549
  inputSchema: z13.object({
1592
1550
  page: z13.number().int().min(0).default(0).describe("Page number"),
@@ -1602,7 +1560,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
1602
1560
  "lexq_webhook_subscriptions_get",
1603
1561
  {
1604
1562
  title: "Get Webhook Subscription",
1605
- annotations: { readOnlyHint: true },
1563
+ annotations: READ_ONLY,
1606
1564
  description: "Get webhook subscription detail by ID.",
1607
1565
  inputSchema: z13.object({
1608
1566
  id: z13.string().uuid().describe("Webhook subscription ID")
@@ -1614,7 +1572,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
1614
1572
  "lexq_webhook_subscriptions_save",
1615
1573
  {
1616
1574
  title: "Save Webhook Subscription",
1617
- annotations: { readOnlyHint: false, destructiveHint: true },
1575
+ annotations: { ...DESTRUCTIVE, openWorldHint: true },
1618
1576
  description: `Create or update a webhook subscription. Omit id to create, provide id to update. Events: ${PlatformEventType.join(", ")}. Formats: ${withNotes(FORMAT_NOTES).join(", ")}.`,
1619
1577
  inputSchema: z13.object({
1620
1578
  id: z13.string().uuid().optional().describe("Subscription ID (omit to create, provide to update)"),
@@ -1632,7 +1590,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
1632
1590
  "lexq_webhook_subscriptions_delete",
1633
1591
  {
1634
1592
  title: "Delete Webhook Subscription",
1635
- annotations: { readOnlyHint: false, destructiveHint: true },
1593
+ annotations: DESTRUCTIVE,
1636
1594
  description: "Delete a webhook subscription by ID.",
1637
1595
  inputSchema: z13.object({
1638
1596
  id: z13.string().uuid().describe("Webhook subscription ID")
@@ -1644,7 +1602,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
1644
1602
  "lexq_webhook_subscriptions_test",
1645
1603
  {
1646
1604
  title: "Test Webhook Subscription",
1647
- annotations: { readOnlyHint: false, destructiveHint: false },
1605
+ annotations: { ...DESTRUCTIVE, openWorldHint: true },
1648
1606
  description: "Send a test event to verify webhook connectivity. Returns the HTTP status code and success/failure message.",
1649
1607
  inputSchema: z13.object({
1650
1608
  id: z13.string().uuid().describe("Webhook subscription ID")
@@ -1661,7 +1619,7 @@ function registerDomainTemplateTools(server, callApi) {
1661
1619
  "lexq_domain_templates_list",
1662
1620
  {
1663
1621
  title: "List Domain Templates",
1664
- annotations: { readOnlyHint: true },
1622
+ annotations: READ_ONLY,
1665
1623
  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.",
1666
1624
  inputSchema: z14.object({})
1667
1625
  },
@@ -1671,7 +1629,7 @@ function registerDomainTemplateTools(server, callApi) {
1671
1629
  "lexq_domain_templates_preview",
1672
1630
  {
1673
1631
  title: "Preview Domain Template",
1674
- annotations: { readOnlyHint: true },
1632
+ annotations: READ_ONLY,
1675
1633
  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.",
1676
1634
  inputSchema: z14.object({
1677
1635
  template: z14.string().describe(
@@ -1685,7 +1643,7 @@ function registerDomainTemplateTools(server, callApi) {
1685
1643
  "lexq_domain_templates_apply",
1686
1644
  {
1687
1645
  title: "Apply Domain Template",
1688
- annotations: { readOnlyHint: false, destructiveHint: false },
1646
+ annotations: ADDITIVE,
1689
1647
  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.",
1690
1648
  inputSchema: z14.object({
1691
1649
  template: z14.string().describe("Domain template key to apply (e.g. ECOMMERCE)."),
@@ -1720,10 +1678,18 @@ function registerAllTools(server, callApi) {
1720
1678
  registerWebhookSubscriptionTools(server, callApi);
1721
1679
  }
1722
1680
  var SERVER_INFO = { name: "lexq", ...server_info_default };
1681
+ var SERVER_INSTRUCTIONS = [
1682
+ "LexQ evaluates business rules: a policy group holds versions, a version holds rules, and rules read input facts.",
1683
+ "- 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.",
1684
+ "- 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.",
1685
+ "- Before lexq_deploy_publish, run lexq_dry_run on the version with representative facts. A published version cannot go back to draft.",
1686
+ `- For anything these tools cannot do, point the user to the LexQ console at ${CONSOLE_URL}.`
1687
+ ].join("\n");
1723
1688
  export {
1724
1689
  ApiError,
1725
1690
  LosslessNumber,
1726
1691
  SERVER_INFO,
1692
+ SERVER_INSTRUCTIONS,
1727
1693
  formatUnregisteredFactWarning,
1728
1694
  formatValueDomainMismatchWarning,
1729
1695
  isLosslessNumber,