@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 +1 -1
- package/dist/index.js +111 -11
- package/dist/mcp/register.js +44 -18
- package/package.json +3 -2
- package/skills/lexq-recipes/SKILL.md +5 -1
- package/skills/lexq-shared/SKILL.md +1 -0
- package/skills/lexq-simulation/SKILL.md +12 -6
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
|
|
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
|
|
1401
|
+
System facts accept name, description, and the PII flag only.
|
|
1383
1402
|
|
|
1384
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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"),
|
package/dist/mcp/register.js
CHANGED
|
@@ -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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
|
192
|
-
|
|
193
|
-
| `
|
|
194
|
-
| `
|
|
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.
|