@lexq/cli 0.4.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -277,7 +277,7 @@ Checks that guard a stated fact against the thing that owns it. All of them run
277
277
 
278
278
  ```bash
279
279
  pnpm enums:check # Generated enums match the engine contract
280
- pnpm constants:check # Numbers written in prose match the contract
280
+ pnpm prose:check # Numbers and code tables in prose match the contract
281
281
  pnpm ab-key:check # Tool descriptions still name the A/B traffic key
282
282
  pnpm surface:check # Nothing internal reached a public file
283
283
  pnpm registry:check # server.json agrees with package.json
package/dist/index.js CHANGED
@@ -72,6 +72,7 @@ var survivesDoubleRoundTrip = (literal) => isSafeNumber(literal);
72
72
  var parseNumber = (value) => survivesDoubleRoundTrip(value) ? parseFloat(value) : new LosslessNumber(value);
73
73
  var parseJson = (text) => losslessParse(text, void 0, parseNumber);
74
74
  var stringifyJson = (value, space) => losslessStringify(value, void 0, space);
75
+ var isLosslessNumber = (value) => value instanceof LosslessNumber;
75
76
 
76
77
  // src/lib/api-client.ts
77
78
  var ApiError = class extends Error {
@@ -346,6 +347,12 @@ var SLOW_MULTIPLIER = 10;
346
347
  var MIN_SAMPLES = 100;
347
348
  var MIN_COHORT_SIZE = 3;
348
349
  var PROFILE_DEFAULT_WINDOW_HOURS = 24;
350
+ var MAX_GROUPS_PER_TENANT = 20;
351
+ var MAX_DRAFT_VERSIONS_PER_GROUP = 20;
352
+ var MAX_RULES_PER_VERSION = 100;
353
+ var MAX_CONDITIONS_PER_RULE = 25;
354
+ var MAX_CONDITION_DEPTH = 10;
355
+ var MAX_SNAPSHOT_BYTES = 131072;
349
356
 
350
357
  // src/commands/groups.ts
351
358
  import "commander";
@@ -1338,7 +1345,7 @@ ${data.length} unregistered`);
1338
1345
  process.exit(1);
1339
1346
  }
1340
1347
  });
1341
- facts.command("create").description("Create a new fact definition").option("--key <key>", "Fact key (letters, numbers, underscores)").option("--name <n>", "Display name").option("--type <type>", "Value type: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER").option("--description <desc>", "Description").option("--required", "Mark as required", false).option("--pii", "Mark as PII \u2014 value is masked on every read surface", false).option("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
1348
+ facts.command("create").description("Create a new fact definition").option("--key <key>", "Fact key (letters, numbers, underscores)").option("--name <n>", "Display name").option("--type <type>", "Value type: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER").option("--description <desc>", "Description").option("--required", "Mark as required", false).option("--pii", "Mark as PII \u2014 value is masked on every read surface", false).option("--allowed-values <csv>", "Comma-separated list of accepted values").option("--min <number>", "Lower bound, inclusive (numeric types only)").option("--max <number>", "Upper bound, inclusive (numeric types only)").option("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
1342
1349
  "after",
1343
1350
  dedent6`
1344
1351
 
@@ -1354,9 +1361,21 @@ ${data.length} unregistered`);
1354
1361
  "isRequired": false
1355
1362
  }'
1356
1363
 
1364
+ $ lexq facts create --key riskScore --name "Risk Score" --type NUMBER --min 0 --max 100
1365
+ $ lexq facts create --key tier --name "Tier" --type STRING \
1366
+ --allowed-values "GOLD, SILVER, BRONZE"
1367
+
1357
1368
  Value Types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER
1358
1369
  Key Format: starts with a letter, then letters, numbers, and underscores.
1359
1370
  Casing is yours to choose (e.g., paymentAmount, payment_amount)
1371
+
1372
+ Value Domain: --allowed-values, --min, and --max say which values the fact accepts.
1373
+ A rule can then only compare against those values, and the console turns
1374
+ the rule editor into a dropdown instead of a free text box.
1375
+ STRING types take --allowed-values. NUMBER types take all three.
1376
+ BOOLEAN takes none — it has only two values to begin with.
1377
+ For list types the constraint applies to each element.
1378
+ Leave them out for no constraint, which is how facts behaved before.
1360
1379
  `
1361
1380
  ).action(async (opts) => {
1362
1381
  try {
@@ -1375,14 +1394,25 @@ ${data.length} unregistered`);
1375
1394
  process.exit(1);
1376
1395
  }
1377
1396
  });
1378
- 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("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
1397
+ 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(
1379
1398
  "after",
1380
1399
  dedent6`
1381
1400
 
1382
- System facts cannot be modified. Only display name, description, and required flag can be changed.
1401
+ System facts accept name, description, and the PII flag only.
1383
1402
 
1384
- Example:
1403
+ Examples:
1385
1404
  $ lexq facts update --id <factId> --name "Updated Name" --required
1405
+ $ lexq facts update --id <factId> --min 0 --max 100
1406
+ $ lexq facts update --id <factId> --clear-value-domain
1407
+
1408
+ Only the flags you pass are changed. Leaving out --allowed-values, --min, and --max
1409
+ leaves the constraint alone; --clear-value-domain removes it.
1410
+
1411
+ Narrowing a constraint does not rewrite rules that already reference this fact. They keep
1412
+ working, and the next time one is saved it is judged against the new constraint.
1413
+
1414
+ Pass --type when the accepted values are numbers and you are not also naming the type,
1415
+ so "10" is sent as a number rather than a string. --json settles it either way.
1386
1416
  `
1387
1417
  ).action(async (opts) => {
1388
1418
  try {
@@ -1496,16 +1526,66 @@ function buildCreateBody2(opts) {
1496
1526
  isPii: opts.pii === true
1497
1527
  };
1498
1528
  if (opts.description) body.description = opts.description;
1529
+ const domain = buildValueDomain(opts, opts.type);
1530
+ if (domain) body.valueDomain = domain;
1499
1531
  return body;
1500
1532
  }
1501
1533
  function buildUpdateBody2(opts) {
1502
1534
  const body = {};
1503
1535
  if (opts.name) body.name = opts.name;
1504
1536
  if (opts.description !== void 0) body.description = opts.description;
1537
+ if (opts.type) body.type = opts.type;
1505
1538
  if (typeof opts.required === "boolean") body.isRequired = opts.required;
1506
1539
  if (typeof opts.isPii === "boolean") body.isPii = opts.isPii;
1540
+ if (opts.clearValueDomain === true) {
1541
+ if (hasDomainFlag(opts)) {
1542
+ throw new Error(
1543
+ "--clear-value-domain cannot be combined with --allowed-values, --min, or --max."
1544
+ );
1545
+ }
1546
+ body.valueDomain = {};
1547
+ return body;
1548
+ }
1549
+ const domain = buildValueDomain(opts, opts.type);
1550
+ if (domain) body.valueDomain = domain;
1507
1551
  return body;
1508
1552
  }
1553
+ function hasDomainFlag(opts) {
1554
+ return opts.allowedValues !== void 0 || opts.min !== void 0 || opts.max !== void 0;
1555
+ }
1556
+ function parseNumericLiteral(text, flag) {
1557
+ let parsed;
1558
+ try {
1559
+ parsed = parseJson(text);
1560
+ } catch {
1561
+ throw new Error(`${flag} must be a number, got: ${text}`);
1562
+ }
1563
+ if (typeof parsed !== "number" && !isLosslessNumber(parsed)) {
1564
+ throw new Error(`${flag} must be a number, got: ${text}`);
1565
+ }
1566
+ return parsed;
1567
+ }
1568
+ function asDomainValue(text, numeric) {
1569
+ if (numeric === true) return parseNumericLiteral(text, "--allowed-values");
1570
+ if (numeric === false) return text;
1571
+ try {
1572
+ const parsed = parseJson(text);
1573
+ return typeof parsed === "number" || isLosslessNumber(parsed) ? parsed : text;
1574
+ } catch {
1575
+ return text;
1576
+ }
1577
+ }
1578
+ function buildValueDomain(opts, type) {
1579
+ if (!hasDomainFlag(opts)) return void 0;
1580
+ const numeric = type === void 0 ? void 0 : type === "NUMBER" || type === "LIST_NUMBER";
1581
+ const domain = {};
1582
+ if (opts.allowedValues !== void 0) {
1583
+ domain.allowedValues = opts.allowedValues.split(",").map((part) => part.trim()).filter((part) => part.length > 0).map((part) => asDomainValue(part, numeric));
1584
+ }
1585
+ if (opts.min !== void 0) domain.min = parseNumericLiteral(opts.min, "--min");
1586
+ if (opts.max !== void 0) domain.max = parseNumericLiteral(opts.max, "--max");
1587
+ return domain;
1588
+ }
1509
1589
 
1510
1590
  // src/commands/deploy.ts
1511
1591
  import "commander";
@@ -3459,7 +3539,7 @@ function registerGroupTools(server, callApi) {
3459
3539
  {
3460
3540
  title: "Create Policy Group",
3461
3541
  annotations: { readOnlyHint: false, destructiveHint: false },
3462
- 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.",
3542
+ 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.`,
3463
3543
  inputSchema: z2.object({
3464
3544
  name: z2.string().describe("Group name (unique among non-ARCHIVED)"),
3465
3545
  description: z2.string().optional().describe("Group description"),
@@ -3616,7 +3696,7 @@ function registerVersionTools(server, callApi) {
3616
3696
  {
3617
3697
  title: "Create Policy Version",
3618
3698
  annotations: { readOnlyHint: false, destructiveHint: false },
3619
- description: "Create a new DRAFT version in a policy group. Optionally provide a commit message and effective date range.",
3699
+ 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.`,
3620
3700
  inputSchema: z3.object({
3621
3701
  groupId: z3.string().uuid().describe("Policy group ID"),
3622
3702
  commitMessage: z3.string().optional().describe("Commit message describing this version"),
@@ -3660,7 +3740,7 @@ function registerVersionTools(server, callApi) {
3660
3740
  {
3661
3741
  title: "Clone Policy Version",
3662
3742
  annotations: { readOnlyHint: false, destructiveHint: false },
3663
- description: "Clone an existing version to create a new DRAFT. Useful when the source version is already published.",
3743
+ 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.`,
3664
3744
  inputSchema: z3.object({
3665
3745
  groupId: z3.string().uuid().describe("Policy group ID"),
3666
3746
  versionId: z3.string().uuid().describe("Source version ID to clone")
@@ -3723,7 +3803,9 @@ function registerRuleTools(server, callApi) {
3723
3803
 
3724
3804
  Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: "AND"|"OR", children: [...] }
3725
3805
  Value types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER
3726
-
3806
+ Limits: a condition tree may hold at most ${MAX_CONDITIONS_PER_RULE} conditions (SINGLE nodes; GROUP nodes do not count)
3807
+ and nest at most ${MAX_CONDITION_DEPTH} levels below the root. A version holds at most ${MAX_RULES_PER_VERSION} rules.
3808
+
3727
3809
  Operators are constrained by the LEFT fact's type (from lexq_facts_list). Using one outside
3728
3810
  its type is rejected by the server — check the fact type before choosing an operator.
3729
3811
  - STRING fact: EQUALS, NOT_EQUALS, CONTAINS, IN, NOT_IN
@@ -3789,7 +3871,7 @@ function registerRuleTools(server, callApi) {
3789
3871
  {
3790
3872
  title: "Update Rule",
3791
3873
  annotations: { readOnlyHint: false, destructiveHint: true },
3792
- description: "Update an existing rule in a DRAFT version. Only provided fields are changed.",
3874
+ 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.`,
3793
3875
  inputSchema: z4.object({
3794
3876
  groupId: z4.string().uuid().describe("Policy group ID"),
3795
3877
  versionId: z4.string().uuid().describe("Version ID"),
@@ -3909,6 +3991,15 @@ function registerFactTools(server, callApi) {
3909
3991
  isRequired: z5.boolean().default(false).describe("Whether this fact is required for rule evaluation"),
3910
3992
  isPii: z5.boolean().default(false).describe(
3911
3993
  "Mark as PII \u2014 masked on every read surface, revealable only in the console (audited)"
3994
+ ),
3995
+ valueDomain: z5.object({
3996
+ allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
3997
+ "Accepted values. For list types the constraint applies to each element, not to the list as a whole."
3998
+ ),
3999
+ min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
4000
+ max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
4001
+ }).optional().describe(
4002
+ "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."
3912
4003
  )
3913
4004
  })
3914
4005
  },
@@ -3928,7 +4019,16 @@ function registerFactTools(server, callApi) {
3928
4019
  "Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule references the fact."
3929
4020
  ),
3930
4021
  isRequired: z5.boolean().optional().describe("Required flag"),
3931
- isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)")
4022
+ isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
4023
+ valueDomain: z5.object({
4024
+ allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
4025
+ "Accepted values. For list types the constraint applies to each element, not to the list as a whole."
4026
+ ),
4027
+ min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
4028
+ max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
4029
+ }).optional().describe(
4030
+ "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."
4031
+ )
3932
4032
  })
3933
4033
  },
3934
4034
  async ({ factId, ...body }) => callApi("PUT", `schema/facts/${factId}`, { body })
@@ -3993,7 +4093,7 @@ function registerDeployTools(server, callApi) {
3993
4093
  {
3994
4094
  title: "Publish Version",
3995
4095
  annotations: { readOnlyHint: false, destructiveHint: false },
3996
- description: "Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule. Undefined facts referenced by rules do not block publishing (INV-4); call lexq_facts_unregistered first to review them.",
4096
+ description: `Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule. Undefined facts referenced by rules do not block publishing (INV-4); call lexq_facts_unregistered first to review them. Publishing fails if the version's compiled rules exceed ${MAX_SNAPSHOT_BYTES / 1024} KB.`,
3997
4097
  inputSchema: z6.object({
3998
4098
  groupId: z6.string().uuid().describe("Policy group ID"),
3999
4099
  versionId: z6.string().uuid().describe("Version ID to publish"),
@@ -34,6 +34,23 @@ var PlatformEventType = [
34
34
  ];
35
35
  var WebhookPayloadFormat = ["GENERIC", "SLACK"];
36
36
 
37
+ // src/types/constants.ts
38
+ var MAX_ROUNDING_SCALE = 34;
39
+ var REPLAY_WINDOW_MAX_RECORDS = 5e4;
40
+ var PROFILE_SAMPLE_PERMILLE = 10;
41
+ var LATENCY_WINDOW_MILLIS = 6e4;
42
+ var TAIL_MIN_OBS = 3;
43
+ var SLOW_MULTIPLIER = 10;
44
+ var MIN_SAMPLES = 100;
45
+ var MIN_COHORT_SIZE = 3;
46
+ var PROFILE_DEFAULT_WINDOW_HOURS = 24;
47
+ var MAX_GROUPS_PER_TENANT = 20;
48
+ var MAX_DRAFT_VERSIONS_PER_GROUP = 20;
49
+ var MAX_RULES_PER_VERSION = 100;
50
+ var MAX_CONDITIONS_PER_RULE = 25;
51
+ var MAX_CONDITION_DEPTH = 10;
52
+ var MAX_SNAPSHOT_BYTES = 131072;
53
+
37
54
  // src/mcp/tools/groups.ts
38
55
  function registerGroupTools(server, callApi) {
39
56
  server.registerTool(
@@ -63,7 +80,7 @@ function registerGroupTools(server, callApi) {
63
80
  {
64
81
  title: "Create Policy Group",
65
82
  annotations: { readOnlyHint: false, destructiveHint: false },
66
- 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.",
83
+ 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.`,
67
84
  inputSchema: z2.object({
68
85
  name: z2.string().describe("Group name (unique among non-ARCHIVED)"),
69
86
  description: z2.string().optional().describe("Group description"),
@@ -278,7 +295,7 @@ function registerVersionTools(server, callApi) {
278
295
  {
279
296
  title: "Create Policy Version",
280
297
  annotations: { readOnlyHint: false, destructiveHint: false },
281
- description: "Create a new DRAFT version in a policy group. Optionally provide a commit message and effective date range.",
298
+ 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.`,
282
299
  inputSchema: z3.object({
283
300
  groupId: z3.string().uuid().describe("Policy group ID"),
284
301
  commitMessage: z3.string().optional().describe("Commit message describing this version"),
@@ -322,7 +339,7 @@ function registerVersionTools(server, callApi) {
322
339
  {
323
340
  title: "Clone Policy Version",
324
341
  annotations: { readOnlyHint: false, destructiveHint: false },
325
- description: "Clone an existing version to create a new DRAFT. Useful when the source version is already published.",
342
+ 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.`,
326
343
  inputSchema: z3.object({
327
344
  groupId: z3.string().uuid().describe("Policy group ID"),
328
345
  versionId: z3.string().uuid().describe("Source version ID to clone")
@@ -336,17 +353,6 @@ function registerVersionTools(server, callApi) {
336
353
  );
337
354
  }
338
355
 
339
- // src/types/constants.ts
340
- var MAX_ROUNDING_SCALE = 34;
341
- var REPLAY_WINDOW_MAX_RECORDS = 5e4;
342
- var PROFILE_SAMPLE_PERMILLE = 10;
343
- var LATENCY_WINDOW_MILLIS = 6e4;
344
- var TAIL_MIN_OBS = 3;
345
- var SLOW_MULTIPLIER = 10;
346
- var MIN_SAMPLES = 100;
347
- var MIN_COHORT_SIZE = 3;
348
- var PROFILE_DEFAULT_WINDOW_HOURS = 24;
349
-
350
356
  // src/mcp/tools/rules.ts
351
357
  import { z as z4 } from "zod";
352
358
  import dedent from "dedent";
@@ -396,7 +402,9 @@ function registerRuleTools(server, callApi) {
396
402
 
397
403
  Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: "AND"|"OR", children: [...] }
398
404
  Value types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER
399
-
405
+ Limits: a condition tree may hold at most ${MAX_CONDITIONS_PER_RULE} conditions (SINGLE nodes; GROUP nodes do not count)
406
+ and nest at most ${MAX_CONDITION_DEPTH} levels below the root. A version holds at most ${MAX_RULES_PER_VERSION} rules.
407
+
400
408
  Operators are constrained by the LEFT fact's type (from lexq_facts_list). Using one outside
401
409
  its type is rejected by the server — check the fact type before choosing an operator.
402
410
  - STRING fact: EQUALS, NOT_EQUALS, CONTAINS, IN, NOT_IN
@@ -462,7 +470,7 @@ function registerRuleTools(server, callApi) {
462
470
  {
463
471
  title: "Update Rule",
464
472
  annotations: { readOnlyHint: false, destructiveHint: true },
465
- description: "Update an existing rule in a DRAFT version. Only provided fields are changed.",
473
+ 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.`,
466
474
  inputSchema: z4.object({
467
475
  groupId: z4.string().uuid().describe("Policy group ID"),
468
476
  versionId: z4.string().uuid().describe("Version ID"),
@@ -586,6 +594,15 @@ function registerFactTools(server, callApi) {
586
594
  isRequired: z5.boolean().default(false).describe("Whether this fact is required for rule evaluation"),
587
595
  isPii: z5.boolean().default(false).describe(
588
596
  "Mark as PII \u2014 masked on every read surface, revealable only in the console (audited)"
597
+ ),
598
+ valueDomain: z5.object({
599
+ allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
600
+ "Accepted values. For list types the constraint applies to each element, not to the list as a whole."
601
+ ),
602
+ min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
603
+ max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
604
+ }).optional().describe(
605
+ "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."
589
606
  )
590
607
  })
591
608
  },
@@ -605,7 +622,16 @@ function registerFactTools(server, callApi) {
605
622
  "Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule references the fact."
606
623
  ),
607
624
  isRequired: z5.boolean().optional().describe("Required flag"),
608
- isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)")
625
+ isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
626
+ valueDomain: z5.object({
627
+ allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
628
+ "Accepted values. For list types the constraint applies to each element, not to the list as a whole."
629
+ ),
630
+ min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
631
+ max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
632
+ }).optional().describe(
633
+ "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."
634
+ )
609
635
  })
610
636
  },
611
637
  async ({ factId, ...body }) => callApi("PUT", `schema/facts/${factId}`, { body })
@@ -670,7 +696,7 @@ function registerDeployTools(server, callApi) {
670
696
  {
671
697
  title: "Publish Version",
672
698
  annotations: { readOnlyHint: false, destructiveHint: false },
673
- description: "Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule. Undefined facts referenced by rules do not block publishing (INV-4); call lexq_facts_unregistered first to review them.",
699
+ description: `Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule. Undefined facts referenced by rules do not block publishing (INV-4); call lexq_facts_unregistered first to review them. Publishing fails if the version's compiled rules exceed ${MAX_SNAPSHOT_BYTES / 1024} KB.`,
674
700
  inputSchema: z6.object({
675
701
  groupId: z6.string().uuid().describe("Policy group ID"),
676
702
  versionId: z6.string().uuid().describe("Version ID to publish"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexq/cli",
3
- "version": "0.4.0",
3
+ "version": "1.0.1",
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",
@@ -30,7 +30,7 @@
30
30
  "typecheck": "tsc --noEmit",
31
31
  "enums": "node scripts/gen-enums.mjs",
32
32
  "enums:check": "node scripts/gen-enums.mjs --check",
33
- "constants:check": "node scripts/check-constants.mjs",
33
+ "prose:check": "node scripts/check-prose-mirrors.mjs",
34
34
  "surface:check": "node scripts/check-public-surface.mjs",
35
35
  "ab-key:check": "node scripts/check-ab-key.mjs",
36
36
  "registry:check": "node scripts/check-registry-manifest.mjs",
@@ -41,6 +41,7 @@
41
41
  "test:decimals": "pnpm build && node tests/exact-decimals.mjs",
42
42
  "test:fact-key": "node tests/fact-key.mjs",
43
43
  "test:export-format": "node tests/export-format.mjs",
44
+ "test:value-domain": "pnpm build && node tests/value-domain.mjs",
44
45
  "prepublishOnly": "pnpm build",
45
46
  "knip": "knip",
46
47
  "format": "prettier --write \"src/**/*.ts\"",
@@ -20,7 +20,11 @@ lexq versions create --group-id <gid> --json '{"commitMessage": "Initial tiered
20
20
 
21
21
  # 3. Register facts (skip if already exist)
22
22
  lexq facts create --key paymentAmount --name "Payment Amount" --type NUMBER --required
23
- lexq facts create --key customerTier --name "Customer Tier" --type STRING
23
+ lexq facts create --key customerTier --name "Customer Tier" --type STRING \
24
+ --allowed-values "VIP, GOLD, SILVER"
25
+ # A value domain says which values the fact accepts. It refuses a rule literal that could
26
+ # never match (a typo like "PLATNUM"), and the Console offers a dropdown instead of a text box.
27
+ # Leave it off when the set is open — a country code, an account id, a free-text label.
24
28
 
25
29
  # 4. Add rules — creation order becomes priority order (first created = priority 1 = highest)
26
30
  lexq rules create --group-id <gid> --version-id <vid> --json '{
@@ -47,6 +47,7 @@ This is the typical lifecycle. **Always follow this order:**
47
47
  1. lexq groups create → Create a policy group
48
48
  2. lexq versions create → Create a DRAFT version inside it
49
49
  3. lexq facts create → Register input variables (if not already defined)
50
+ → --allowed-values / --min / --max constrain what callers may send
50
51
  4. lexq rules create → Add rules with conditions + actions
51
52
  5. lexq analytics dry-run → Test with sample facts (validate before publish)
52
53
  6. lexq deploy publish → DRAFT → ACTIVE (locks the version)
@@ -186,12 +186,18 @@ rule you expect never appears in `decisionTraces`, check the version's `effectiv
186
186
  - **empty** — the condition was evaluated and did not match
187
187
  - **`Evaluation error: <code>`** — the condition could not be evaluated at all
188
188
 
189
- | Code | Meaning |
190
- |-------------------------|--------------------------------------------------------------------|
191
- | `FACT_NOT_PROVIDED` | The rule references a fact absent from the request |
192
- | `FACT_TYPE_MISMATCH` | The fact's runtime type does not match the condition |
193
- | `UNSUPPORTED_FACT_TYPE` | Operator not valid for the fact's type (e.g. `CONTAINS` on a list) |
194
- | `MALFORMED_RULE` | The stored rule is structurally invalid |
189
+ Each code says **who fixes it**.
190
+
191
+ | Code | Who fixes it | Meaning |
192
+ |--------------------------------|--------------|------------------------------------------------------------|
193
+ | `FACT_NOT_PROVIDED` | the caller | The rule reads a fact the request did not include |
194
+ | `FACT_TYPE_MISMATCH` | the caller | The value sent has the wrong type for the condition |
195
+ | `CONDITION_VALUE_TYPE_INVALID` | the author | The literal stored in the rule is wrong for its operator |
196
+ | `MALFORMED_RULE` | nobody | The stored rule is structurally invalid. Report it |
197
+
198
+ `CONDITION_VALUE_TYPE_INVALID` is worth escalating. The right-hand side of a condition belongs
199
+ to the rule, not to your request, so changing the payload will not help — the rule fails the
200
+ same way for every caller. It also got past the check that is supposed to reject it on save.
195
201
 
196
202
  A rule from another group referencing facts you did not send yields `FACT_NOT_PROVIDED` — this
197
203
  is normal, not an error.