@lexq/cli 1.1.5 → 1.1.7

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
@@ -608,7 +608,10 @@ ${data.length} total`);
608
608
  The remaining traffic continues to the current live version.
609
609
  `
610
610
  );
611
- abTest.command("start").description("Start an A/B test").requiredOption("--group-id <groupId>", "Policy group ID").requiredOption("--version-id <versionId>", "Challenger version ID").requiredOption(
611
+ abTest.command("start").description("Start an A/B test").requiredOption("--group-id <groupId>", "Policy group ID").requiredOption(
612
+ "--version-id <versionId>",
613
+ "Challenger version ID (a published version of this group)"
614
+ ).requiredOption(
612
615
  "--traffic-rate <rate>",
613
616
  `Traffic rate for challenger (${MIN_TRAFFIC_RATE}-${MAX_TRAFFIC_RATE})`
614
617
  ).addHelpText(
@@ -1234,6 +1237,46 @@ ${data.length} total`);
1234
1237
  import "commander";
1235
1238
  import dedent6 from "dedent";
1236
1239
 
1240
+ // src/lib/value-domain-warning.ts
1241
+ function formatValueDomainMismatchWarning(meta) {
1242
+ const mismatch = meta?.valueDomainMismatch;
1243
+ if (!mismatch || !Array.isArray(mismatch.versions) || mismatch.versions.length === 0) return null;
1244
+ const n = mismatch.totalRuleCount;
1245
+ const subject = n === 1 ? "1 rule compares" : `${n} rules compare`;
1246
+ const lines = [
1247
+ `\u26A0 ${subject} ${mismatch.factKey} with values the new value domain does not accept. The update was still saved.`
1248
+ ];
1249
+ for (const version of mismatch.versions) {
1250
+ lines.push(...versionLines(mismatch.factKey, version));
1251
+ }
1252
+ return lines.join("\n");
1253
+ }
1254
+ function versionLines(factKey, version) {
1255
+ const draft = version.versionStatus === "DRAFT";
1256
+ const lines = [
1257
+ ` ${quoted(version.groupName)} v${version.versionNo}, ${draft ? "draft" : "deployed"} (groupId ${version.groupId}, versionId ${version.versionId})`,
1258
+ 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."
1259
+ ];
1260
+ for (const rule of version.rules) {
1261
+ const conditions = rule.conditions.map(
1262
+ (c) => `${factKey} ${c.operator} ${escapeUnsafe(stringifyJson(c.value) ?? String(c.value))}`
1263
+ ).join("; ");
1264
+ lines.push(` \u2022 ${quoted(rule.ruleName)} (ruleId ${rule.ruleId}): ${conditions}`);
1265
+ }
1266
+ const hidden = version.ruleCount - version.rules.length;
1267
+ if (hidden > 0) {
1268
+ lines.push(` \u2026 and ${hidden} more rule${hidden === 1 ? "" : "s"} in this version.`);
1269
+ }
1270
+ return lines;
1271
+ }
1272
+ function quoted(name) {
1273
+ return escapeUnsafe(stringifyJson(String(name)) ?? '""');
1274
+ }
1275
+ var UNSAFE = /[\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
1276
+ function escapeUnsafe(json) {
1277
+ return json.replace(UNSAFE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
1278
+ }
1279
+
1237
1280
  // src/lib/export.ts
1238
1281
  import { writeFileSync as writeFileSync2 } from "fs";
1239
1282
 
@@ -1420,8 +1463,8 @@ ${data.length} unregistered`);
1420
1463
  the rule editor into a dropdown instead of a free text box.
1421
1464
  STRING types take --allowed-values. NUMBER types take all three.
1422
1465
  BOOLEAN takes none — it has only two values to begin with.
1423
- For list types the constraint applies to each element.
1424
- Leave them out for no constraint, which is how facts behaved before.
1466
+ For list types the value domain applies to each element.
1467
+ Leave them out for no value domain, which is how facts behaved before.
1425
1468
  `
1426
1469
  ).action(async (opts) => {
1427
1470
  try {
@@ -1440,7 +1483,7 @@ ${data.length} unregistered`);
1440
1483
  process.exit(1);
1441
1484
  }
1442
1485
  });
1443
- facts.command("update").description("Update a fact definition").requiredOption("--id <factId>", "Fact definition ID").option("--name <n>", "Display name").option("--description <desc>", "Description").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 constraint entirely").option("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
1486
+ 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(
1444
1487
  "after",
1445
1488
  dedent6`
1446
1489
 
@@ -1453,10 +1496,11 @@ ${data.length} unregistered`);
1453
1496
  $ lexq facts update --id <factId> --clear-value-domain
1454
1497
 
1455
1498
  Only the flags you pass are changed. Leaving out --allowed-values, --min, and --max
1456
- leaves the constraint alone; --clear-value-domain removes it.
1499
+ leaves the value domain alone; --clear-value-domain removes it.
1457
1500
 
1458
- Narrowing a constraint does not rewrite rules that already reference this fact. They keep
1459
- working, and the next time one is saved it is judged against the new constraint.
1501
+ Narrowing the value domain never blocks the update and does not rewrite rules. When rules
1502
+ compare this fact with a value the new value domain excludes, a warning on stderr lists them
1503
+ by version: drafts and the versions currently deployed.
1460
1504
 
1461
1505
  Pass --type when the accepted values are numbers and you are not also naming the type,
1462
1506
  so "10" is sent as a number rather than a string. --json settles it either way.
@@ -1465,14 +1509,22 @@ ${data.length} unregistered`);
1465
1509
  try {
1466
1510
  const globalOpts = program.opts();
1467
1511
  const body = opts.json ? JSON.parse(opts.json) : buildUpdateBody2(opts);
1468
- const data = await apiRequest("PUT", `schema/facts/${opts.id}`, {
1469
- apiKey: globalOpts.apiKey,
1470
- baseUrl: globalOpts.baseUrl,
1471
- dryRun: globalOpts.dryRun,
1472
- verbose: globalOpts.verbose,
1473
- body
1474
- });
1512
+ const { data, meta } = await apiRequestWithMeta(
1513
+ "PUT",
1514
+ `schema/facts/${opts.id}`,
1515
+ {
1516
+ apiKey: globalOpts.apiKey,
1517
+ baseUrl: globalOpts.baseUrl,
1518
+ dryRun: globalOpts.dryRun,
1519
+ verbose: globalOpts.verbose,
1520
+ body
1521
+ }
1522
+ );
1475
1523
  printJson(data);
1524
+ const warning = formatValueDomainMismatchWarning(meta);
1525
+ if (warning) console.error(`
1526
+ ${warning}
1527
+ `);
1476
1528
  } catch (error) {
1477
1529
  printError(error);
1478
1530
  process.exit(1);
@@ -1583,7 +1635,7 @@ function buildUpdateBody2(opts) {
1583
1635
  if (opts.description !== void 0) body.description = opts.description;
1584
1636
  if (opts.type) body.type = opts.type;
1585
1637
  if (typeof opts.required === "boolean") body.isRequired = opts.required;
1586
- if (typeof opts.isPii === "boolean") body.isPii = opts.isPii;
1638
+ if (typeof opts.pii === "boolean") body.isPii = opts.pii;
1587
1639
  if (opts.clearValueDomain === true) {
1588
1640
  if (hasDomainFlag(opts)) {
1589
1641
  throw new Error(
@@ -3531,6 +3583,8 @@ function createCallApiFromConfig() {
3531
3583
  ];
3532
3584
  const warning = formatUnregisteredFactWarning(meta);
3533
3585
  if (warning) content.push({ type: "text", text: warning });
3586
+ const domainWarning = formatValueDomainMismatchWarning(meta);
3587
+ if (domainWarning) content.push({ type: "text", text: domainWarning });
3534
3588
  return { content };
3535
3589
  } catch (error) {
3536
3590
  const message = error instanceof Error ? error.message : String(error);
@@ -3709,10 +3763,10 @@ function registerGroupTools(server, callApi) {
3709
3763
  {
3710
3764
  title: "Start A/B Test",
3711
3765
  annotations: { readOnlyHint: false, destructiveHint: false },
3712
- description: "Start an A/B test on a policy group. Requires a challenger version ID and traffic rate. 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%.",
3766
+ 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%.",
3713
3767
  inputSchema: z2.object({
3714
3768
  groupId: z2.string().uuid().describe("Policy group ID"),
3715
- testVersionId: z2.string().uuid().describe("Challenger version ID to test"),
3769
+ testVersionId: z2.string().uuid().describe("Challenger version ID. Must be a published version of this group"),
3716
3770
  trafficRate: z2.number().int().min(1).max(99).describe("Traffic percentage routed to challenger (1-99)")
3717
3771
  })
3718
3772
  },
@@ -4100,12 +4154,12 @@ function registerFactTools(server, callApi) {
4100
4154
  ),
4101
4155
  valueDomain: z5.object({
4102
4156
  allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
4103
- "Accepted values. For list types the constraint applies to each element, not to the list as a whole."
4157
+ "Accepted values. For list types the value domain applies to each element, not to the list as a whole."
4104
4158
  ),
4105
4159
  min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
4106
4160
  max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
4107
4161
  }).optional().describe(
4108
- "Which values this fact accepts. Omit for no constraint. STRING and LIST_STRING take allowedValues only; NUMBER and LIST_NUMBER take all three; BOOLEAN takes none. Declaring it lets the rule editor offer a dropdown and rejects a rule literal that could never match."
4162
+ "Which values this fact accepts. Omit for no value domain. STRING and LIST_STRING take allowedValues only; NUMBER and LIST_NUMBER take all three; BOOLEAN takes none. Declaring it lets the rule editor offer a dropdown and rejects a rule literal that could never match."
4109
4163
  )
4110
4164
  })
4111
4165
  },
@@ -4120,7 +4174,7 @@ function registerFactTools(server, callApi) {
4120
4174
  inputSchema: z5.object({
4121
4175
  factId: z5.string().uuid().describe("Fact definition ID"),
4122
4176
  name: z5.string().optional().describe("Display name"),
4123
- description: z5.string().optional().describe("Description"),
4177
+ description: z5.string().optional().describe('Description. Send "" to remove it.'),
4124
4178
  type: z5.enum(VALUE_TYPES).optional().describe(
4125
4179
  "Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule outside a deleted group references the fact."
4126
4180
  ),
@@ -4128,12 +4182,12 @@ function registerFactTools(server, callApi) {
4128
4182
  isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
4129
4183
  valueDomain: z5.object({
4130
4184
  allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
4131
- "Accepted values. For list types the constraint applies to each element, not to the list as a whole."
4185
+ "Accepted values. For list types the value domain applies to each element, not to the list as a whole."
4132
4186
  ),
4133
4187
  min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
4134
4188
  max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
4135
4189
  }).optional().describe(
4136
- "Which values this fact accepts. Three states: omit the field to leave the constraint alone, send {} to remove it, or send a populated object to replace it. Narrowing it does not rewrite rules that already reference this fact \u2014 they keep working, and the next save is judged against the new constraint."
4190
+ "Which values this fact accepts. Three states: omit the field to leave the value domain alone, send {} to remove it, or send a populated object to replace it. Narrowing it never blocks the update and does not rewrite rules. When rules compare this fact with a value the new value domain excludes, the result ends with a warning that lists them by version: drafts and the versions currently deployed."
4137
4191
  )
4138
4192
  })
4139
4193
  },
@@ -5022,7 +5076,7 @@ function registerDomainTemplateTools(server, callApi) {
5022
5076
  {
5023
5077
  title: "Preview Domain Template",
5024
5078
  annotations: { readOnlyHint: true },
5025
- description: "Preview exactly what a domain template will provision before applying it: the fact definitions it registers (an input with a constraint 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.",
5079
+ 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.",
5026
5080
  inputSchema: z14.object({
5027
5081
  template: z14.string().describe(
5028
5082
  "Domain template key (e.g. ECOMMERCE). Use lexq_domain_templates_list to see the keys."
@@ -37,6 +37,30 @@ declare function paginationParams(page?: number, size?: number): Record<string,
37
37
  */
38
38
  declare function formatUnregisteredFactWarning(meta: unknown): string | null;
39
39
 
40
+ /**
41
+ * Builds the warning text for meta.valueDomainMismatch, which a fact update returns when its new
42
+ * value domain excludes values that existing rules compare against. Returns null when the
43
+ * response carries none.
44
+ *
45
+ * The CLI and the MCP server print the same text: `lexq facts update` writes it to stderr and the
46
+ * stdio MCP server appends it to the tool result. It is exported from `@lexq/cli/mcp` so a server
47
+ * that hosts these tools can append it too. The ids are included so an agent can go straight to
48
+ * the rule.
49
+ *
50
+ * The advice depends on the version. A draft is checked against the new value domain already: its
51
+ * dry runs reject these values now, and live calls will too once it is published and deployed. A
52
+ * deployed version keeps the fact definitions captured when it was published, so calls to it are
53
+ * unaffected, but a version cloned from it carries the rule. Values are printed as the rule holds
54
+ * them; this text does not judge whether a condition became always true or always false, so it
55
+ * does not say whether to change a value or remove the condition.
56
+ *
57
+ * Group and rule names and condition values are written by users, so they are printed as JSON
58
+ * strings with control characters (C0 and C1), line and paragraph separators, and text-direction
59
+ * controls escaped. They cannot drive the terminal, break the layout with a new line, reorder the
60
+ * text around them, or pass as part of the warning an agent reads.
61
+ */
62
+ declare function formatValueDomainMismatchWarning(meta: unknown): string | null;
63
+
40
64
  declare class ApiError extends Error {
41
65
  readonly statusCode: number;
42
66
  readonly errorCode: string | null;
@@ -73,4 +97,4 @@ declare function registerAllTools(server: McpServer, callApi: CallApi): void;
73
97
  */
74
98
  declare const SERVER_INFO: Omit<Implementation, 'version'>;
75
99
 
76
- export { ApiError, type CallApi, type McpToolResult, SERVER_INFO, formatUnregisteredFactWarning, isLosslessNumber, paginationParams, parseJson, registerAllTools, stringifyJson };
100
+ export { ApiError, type CallApi, type McpToolResult, SERVER_INFO, formatUnregisteredFactWarning, formatValueDomainMismatchWarning, isLosslessNumber, paginationParams, parseJson, registerAllTools, stringifyJson };
@@ -182,10 +182,10 @@ function registerGroupTools(server, callApi) {
182
182
  {
183
183
  title: "Start A/B Test",
184
184
  annotations: { readOnlyHint: false, destructiveHint: false },
185
- description: "Start an A/B test on a policy group. Requires a challenger version ID and traffic rate. 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%.",
185
+ 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%.",
186
186
  inputSchema: z2.object({
187
187
  groupId: z2.string().uuid().describe("Policy group ID"),
188
- testVersionId: z2.string().uuid().describe("Challenger version ID to test"),
188
+ testVersionId: z2.string().uuid().describe("Challenger version ID. Must be a published version of this group"),
189
189
  trafficRate: z2.number().int().min(1).max(99).describe("Traffic percentage routed to challenger (1-99)")
190
190
  })
191
191
  },
@@ -253,6 +253,46 @@ var ApiError = class extends Error {
253
253
  errorCode;
254
254
  };
255
255
 
256
+ // src/lib/value-domain-warning.ts
257
+ function formatValueDomainMismatchWarning(meta) {
258
+ const mismatch = meta?.valueDomainMismatch;
259
+ if (!mismatch || !Array.isArray(mismatch.versions) || mismatch.versions.length === 0) return null;
260
+ const n = mismatch.totalRuleCount;
261
+ const subject = n === 1 ? "1 rule compares" : `${n} rules compare`;
262
+ const lines = [
263
+ `\u26A0 ${subject} ${mismatch.factKey} with values the new value domain does not accept. The update was still saved.`
264
+ ];
265
+ for (const version of mismatch.versions) {
266
+ lines.push(...versionLines(mismatch.factKey, version));
267
+ }
268
+ return lines.join("\n");
269
+ }
270
+ function versionLines(factKey, version) {
271
+ const draft = version.versionStatus === "DRAFT";
272
+ const lines = [
273
+ ` ${quoted(version.groupName)} v${version.versionNo}, ${draft ? "draft" : "deployed"} (groupId ${version.groupId}, versionId ${version.versionId})`,
274
+ 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."
275
+ ];
276
+ for (const rule of version.rules) {
277
+ const conditions = rule.conditions.map(
278
+ (c) => `${factKey} ${c.operator} ${escapeUnsafe(stringifyJson(c.value) ?? String(c.value))}`
279
+ ).join("; ");
280
+ lines.push(` \u2022 ${quoted(rule.ruleName)} (ruleId ${rule.ruleId}): ${conditions}`);
281
+ }
282
+ const hidden = version.ruleCount - version.rules.length;
283
+ if (hidden > 0) {
284
+ lines.push(` \u2026 and ${hidden} more rule${hidden === 1 ? "" : "s"} in this version.`);
285
+ }
286
+ return lines;
287
+ }
288
+ function quoted(name) {
289
+ return escapeUnsafe(stringifyJson(String(name)) ?? '""');
290
+ }
291
+ var UNSAFE = /[\u007f-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]/g;
292
+ function escapeUnsafe(json) {
293
+ return json.replace(UNSAFE, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
294
+ }
295
+
256
296
  // src/mcp/tools/_shared.ts
257
297
  function paginationParams(page, size) {
258
298
  const params = {};
@@ -646,12 +686,12 @@ function registerFactTools(server, callApi) {
646
686
  ),
647
687
  valueDomain: z5.object({
648
688
  allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
649
- "Accepted values. For list types the constraint applies to each element, not to the list as a whole."
689
+ "Accepted values. For list types the value domain applies to each element, not to the list as a whole."
650
690
  ),
651
691
  min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
652
692
  max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
653
693
  }).optional().describe(
654
- "Which values this fact accepts. Omit for no constraint. STRING and LIST_STRING take allowedValues only; NUMBER and LIST_NUMBER take all three; BOOLEAN takes none. Declaring it lets the rule editor offer a dropdown and rejects a rule literal that could never match."
694
+ "Which values this fact accepts. Omit for no value domain. STRING and LIST_STRING take allowedValues only; NUMBER and LIST_NUMBER take all three; BOOLEAN takes none. Declaring it lets the rule editor offer a dropdown and rejects a rule literal that could never match."
655
695
  )
656
696
  })
657
697
  },
@@ -666,7 +706,7 @@ function registerFactTools(server, callApi) {
666
706
  inputSchema: z5.object({
667
707
  factId: z5.string().uuid().describe("Fact definition ID"),
668
708
  name: z5.string().optional().describe("Display name"),
669
- description: z5.string().optional().describe("Description"),
709
+ description: z5.string().optional().describe('Description. Send "" to remove it.'),
670
710
  type: z5.enum(VALUE_TYPES).optional().describe(
671
711
  "Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule outside a deleted group references the fact."
672
712
  ),
@@ -674,12 +714,12 @@ function registerFactTools(server, callApi) {
674
714
  isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
675
715
  valueDomain: z5.object({
676
716
  allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
677
- "Accepted values. For list types the constraint applies to each element, not to the list as a whole."
717
+ "Accepted values. For list types the value domain applies to each element, not to the list as a whole."
678
718
  ),
679
719
  min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
680
720
  max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
681
721
  }).optional().describe(
682
- "Which values this fact accepts. Three states: omit the field to leave the constraint alone, send {} to remove it, or send a populated object to replace it. Narrowing it does not rewrite rules that already reference this fact \u2014 they keep working, and the next save is judged against the new constraint."
722
+ "Which values this fact accepts. Three states: omit the field to leave the value domain alone, send {} to remove it, or send a populated object to replace it. Narrowing it never blocks the update and does not rewrite rules. When rules compare this fact with a value the new value domain excludes, the result ends with a warning that lists them by version: drafts and the versions currently deployed."
683
723
  )
684
724
  })
685
725
  },
@@ -1568,7 +1608,7 @@ function registerDomainTemplateTools(server, callApi) {
1568
1608
  {
1569
1609
  title: "Preview Domain Template",
1570
1610
  annotations: { readOnlyHint: true },
1571
- description: "Preview exactly what a domain template will provision before applying it: the fact definitions it registers (an input with a constraint 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.",
1611
+ 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.",
1572
1612
  inputSchema: z14.object({
1573
1613
  template: z14.string().describe(
1574
1614
  "Domain template key (e.g. ECOMMERCE). Use lexq_domain_templates_list to see the keys."
@@ -1621,6 +1661,7 @@ export {
1621
1661
  LosslessNumber,
1622
1662
  SERVER_INFO,
1623
1663
  formatUnregisteredFactWarning,
1664
+ formatValueDomainMismatchWarning,
1624
1665
  isLosslessNumber,
1625
1666
  paginationParams,
1626
1667
  parseJson,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexq/cli",
3
- "version": "1.1.5",
3
+ "version": "1.1.7",
4
4
  "mcpName": "io.lexq/lexq",
5
5
  "description": "LexQ CLI — manage policies, simulate rules, and deploy from the terminal. Built for humans and AI agents.",
6
6
  "type": "module",
@@ -43,6 +43,7 @@
43
43
  "test:export-format": "node tests/export-format.mjs",
44
44
  "test:command-options": "node tests/command-options.mjs",
45
45
  "test:value-domain": "pnpm build && node tests/value-domain.mjs",
46
+ "test:fact-update": "pnpm build && node tests/fact-update.mjs",
46
47
  "prepublishOnly": "pnpm build",
47
48
  "knip": "knip",
48
49
  "format": "prettier --write \"src/**/*.ts\"",
@@ -220,8 +220,9 @@ lexq groups ab-test start --group-id <groupId> --version-id <activeVersionId> --
220
220
 
221
221
  `--traffic-rate` is the percentage (1–99) of traffic routed to the test version.
222
222
 
223
- The test version must be `ACTIVE` (published) and different from the live version. Starting a test cancels any pending
224
- scheduled deployment for the group.
223
+ The test version must be a version of the same group, `ACTIVE` (published), and different from the live version. A
224
+ version from another group is rejected with `P-016`. Starting a test cancels any pending scheduled deployment for the
225
+ group.
225
226
 
226
227
  ### Adjust Traffic
227
228