@lexq/cli 0.4.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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 {
@@ -1338,7 +1339,7 @@ ${data.length} unregistered`);
1338
1339
  process.exit(1);
1339
1340
  }
1340
1341
  });
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(
1342
+ 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
1343
  "after",
1343
1344
  dedent6`
1344
1345
 
@@ -1354,9 +1355,21 @@ ${data.length} unregistered`);
1354
1355
  "isRequired": false
1355
1356
  }'
1356
1357
 
1358
+ $ lexq facts create --key riskScore --name "Risk Score" --type NUMBER --min 0 --max 100
1359
+ $ lexq facts create --key tier --name "Tier" --type STRING \
1360
+ --allowed-values "GOLD, SILVER, BRONZE"
1361
+
1357
1362
  Value Types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER
1358
1363
  Key Format: starts with a letter, then letters, numbers, and underscores.
1359
1364
  Casing is yours to choose (e.g., paymentAmount, payment_amount)
1365
+
1366
+ Value Domain: --allowed-values, --min, and --max say which values the fact accepts.
1367
+ A rule can then only compare against those values, and the console turns
1368
+ the rule editor into a dropdown instead of a free text box.
1369
+ STRING types take --allowed-values. NUMBER types take all three.
1370
+ BOOLEAN takes none — it has only two values to begin with.
1371
+ For list types the constraint applies to each element.
1372
+ Leave them out for no constraint, which is how facts behaved before.
1360
1373
  `
1361
1374
  ).action(async (opts) => {
1362
1375
  try {
@@ -1375,14 +1388,25 @@ ${data.length} unregistered`);
1375
1388
  process.exit(1);
1376
1389
  }
1377
1390
  });
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(
1391
+ 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
1392
  "after",
1380
1393
  dedent6`
1381
1394
 
1382
- System facts cannot be modified. Only display name, description, and required flag can be changed.
1395
+ System facts accept name, description, and the PII flag only.
1383
1396
 
1384
- Example:
1397
+ Examples:
1385
1398
  $ lexq facts update --id <factId> --name "Updated Name" --required
1399
+ $ lexq facts update --id <factId> --min 0 --max 100
1400
+ $ lexq facts update --id <factId> --clear-value-domain
1401
+
1402
+ Only the flags you pass are changed. Leaving out --allowed-values, --min, and --max
1403
+ leaves the constraint alone; --clear-value-domain removes it.
1404
+
1405
+ Narrowing a constraint does not rewrite rules that already reference this fact. They keep
1406
+ working, and the next time one is saved it is judged against the new constraint.
1407
+
1408
+ Pass --type when the accepted values are numbers and you are not also naming the type,
1409
+ so "10" is sent as a number rather than a string. --json settles it either way.
1386
1410
  `
1387
1411
  ).action(async (opts) => {
1388
1412
  try {
@@ -1496,16 +1520,66 @@ function buildCreateBody2(opts) {
1496
1520
  isPii: opts.pii === true
1497
1521
  };
1498
1522
  if (opts.description) body.description = opts.description;
1523
+ const domain = buildValueDomain(opts, opts.type);
1524
+ if (domain) body.valueDomain = domain;
1499
1525
  return body;
1500
1526
  }
1501
1527
  function buildUpdateBody2(opts) {
1502
1528
  const body = {};
1503
1529
  if (opts.name) body.name = opts.name;
1504
1530
  if (opts.description !== void 0) body.description = opts.description;
1531
+ if (opts.type) body.type = opts.type;
1505
1532
  if (typeof opts.required === "boolean") body.isRequired = opts.required;
1506
1533
  if (typeof opts.isPii === "boolean") body.isPii = opts.isPii;
1534
+ if (opts.clearValueDomain === true) {
1535
+ if (hasDomainFlag(opts)) {
1536
+ throw new Error(
1537
+ "--clear-value-domain cannot be combined with --allowed-values, --min, or --max."
1538
+ );
1539
+ }
1540
+ body.valueDomain = {};
1541
+ return body;
1542
+ }
1543
+ const domain = buildValueDomain(opts, opts.type);
1544
+ if (domain) body.valueDomain = domain;
1507
1545
  return body;
1508
1546
  }
1547
+ function hasDomainFlag(opts) {
1548
+ return opts.allowedValues !== void 0 || opts.min !== void 0 || opts.max !== void 0;
1549
+ }
1550
+ function parseNumericLiteral(text, flag) {
1551
+ let parsed;
1552
+ try {
1553
+ parsed = parseJson(text);
1554
+ } catch {
1555
+ throw new Error(`${flag} must be a number, got: ${text}`);
1556
+ }
1557
+ if (typeof parsed !== "number" && !isLosslessNumber(parsed)) {
1558
+ throw new Error(`${flag} must be a number, got: ${text}`);
1559
+ }
1560
+ return parsed;
1561
+ }
1562
+ function asDomainValue(text, numeric) {
1563
+ if (numeric === true) return parseNumericLiteral(text, "--allowed-values");
1564
+ if (numeric === false) return text;
1565
+ try {
1566
+ const parsed = parseJson(text);
1567
+ return typeof parsed === "number" || isLosslessNumber(parsed) ? parsed : text;
1568
+ } catch {
1569
+ return text;
1570
+ }
1571
+ }
1572
+ function buildValueDomain(opts, type) {
1573
+ if (!hasDomainFlag(opts)) return void 0;
1574
+ const numeric = type === void 0 ? void 0 : type === "NUMBER" || type === "LIST_NUMBER";
1575
+ const domain = {};
1576
+ if (opts.allowedValues !== void 0) {
1577
+ domain.allowedValues = opts.allowedValues.split(",").map((part) => part.trim()).filter((part) => part.length > 0).map((part) => asDomainValue(part, numeric));
1578
+ }
1579
+ if (opts.min !== void 0) domain.min = parseNumericLiteral(opts.min, "--min");
1580
+ if (opts.max !== void 0) domain.max = parseNumericLiteral(opts.max, "--max");
1581
+ return domain;
1582
+ }
1509
1583
 
1510
1584
  // src/commands/deploy.ts
1511
1585
  import "commander";
@@ -3909,6 +3983,15 @@ function registerFactTools(server, callApi) {
3909
3983
  isRequired: z5.boolean().default(false).describe("Whether this fact is required for rule evaluation"),
3910
3984
  isPii: z5.boolean().default(false).describe(
3911
3985
  "Mark as PII \u2014 masked on every read surface, revealable only in the console (audited)"
3986
+ ),
3987
+ valueDomain: z5.object({
3988
+ allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
3989
+ "Accepted values. For list types the constraint applies to each element, not to the list as a whole."
3990
+ ),
3991
+ min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
3992
+ max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
3993
+ }).optional().describe(
3994
+ "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
3995
  )
3913
3996
  })
3914
3997
  },
@@ -3928,7 +4011,16 @@ function registerFactTools(server, callApi) {
3928
4011
  "Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule references the fact."
3929
4012
  ),
3930
4013
  isRequired: z5.boolean().optional().describe("Required flag"),
3931
- isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)")
4014
+ isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
4015
+ valueDomain: z5.object({
4016
+ allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
4017
+ "Accepted values. For list types the constraint applies to each element, not to the list as a whole."
4018
+ ),
4019
+ min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
4020
+ max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
4021
+ }).optional().describe(
4022
+ "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."
4023
+ )
3932
4024
  })
3933
4025
  },
3934
4026
  async ({ factId, ...body }) => callApi("PUT", `schema/facts/${factId}`, { body })
@@ -586,6 +586,15 @@ function registerFactTools(server, callApi) {
586
586
  isRequired: z5.boolean().default(false).describe("Whether this fact is required for rule evaluation"),
587
587
  isPii: z5.boolean().default(false).describe(
588
588
  "Mark as PII \u2014 masked on every read surface, revealable only in the console (audited)"
589
+ ),
590
+ valueDomain: z5.object({
591
+ allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
592
+ "Accepted values. For list types the constraint applies to each element, not to the list as a whole."
593
+ ),
594
+ min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
595
+ max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
596
+ }).optional().describe(
597
+ "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
598
  )
590
599
  })
591
600
  },
@@ -605,7 +614,16 @@ function registerFactTools(server, callApi) {
605
614
  "Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule references the fact."
606
615
  ),
607
616
  isRequired: z5.boolean().optional().describe("Required flag"),
608
- isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)")
617
+ isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
618
+ valueDomain: z5.object({
619
+ allowedValues: z5.array(z5.union([z5.string(), z5.number()])).optional().describe(
620
+ "Accepted values. For list types the constraint applies to each element, not to the list as a whole."
621
+ ),
622
+ min: z5.number().optional().describe("Lower bound, inclusive. Numeric types only."),
623
+ max: z5.number().optional().describe("Upper bound, inclusive. Numeric types only.")
624
+ }).optional().describe(
625
+ "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."
626
+ )
609
627
  })
610
628
  },
611
629
  async ({ factId, ...body }) => callApi("PUT", `schema/facts/${factId}`, { body })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexq/cli",
3
- "version": "0.4.0",
3
+ "version": "1.0.0",
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.