@lexq/cli 0.1.38 → 0.1.40
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 +202 -17
- package/dist/mcp/register.d.ts +7 -1
- package/dist/mcp/register.js +76 -9
- package/package.json +7 -5
- package/skills/lexq-recipes/SKILL.md +71 -2
- package/skills/lexq-rules/SKILL.md +56 -14
- package/skills/lexq-simulation/SKILL.md +38 -18
package/dist/index.js
CHANGED
|
@@ -933,9 +933,27 @@ ${data.length} total`);
|
|
|
933
933
|
]
|
|
934
934
|
}'
|
|
935
935
|
|
|
936
|
-
Condition Operators:
|
|
937
|
-
EQUALS, NOT_EQUALS,
|
|
938
|
-
|
|
936
|
+
Condition Operators (by fact type):
|
|
937
|
+
STRING EQUALS, NOT_EQUALS, CONTAINS, IN, NOT_IN
|
|
938
|
+
NUMBER EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL,
|
|
939
|
+
LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
|
|
940
|
+
BOOLEAN EQUALS, NOT_EQUALS
|
|
941
|
+
LIST_STRING HAS_ANY, HAS_ALL, HAS_NONE
|
|
942
|
+
LIST_NUMBER HAS_ANY, HAS_ALL, HAS_NONE
|
|
943
|
+
|
|
944
|
+
Using an operator outside its fact type is rejected by the server.
|
|
945
|
+
|
|
946
|
+
List-typed facts (HAS_*) — the value is always an array:
|
|
947
|
+
HAS_ANY fact has at least one of the given values
|
|
948
|
+
HAS_ALL fact has all of the given values
|
|
949
|
+
HAS_NONE fact has none of the given values
|
|
950
|
+
|
|
951
|
+
Example:
|
|
952
|
+
{ "type": "SINGLE", "field": "user_tags", "operator": "HAS_ANY",
|
|
953
|
+
"value": ["VIP", "GOLD"], "valueType": "LIST_STRING" }
|
|
954
|
+
|
|
955
|
+
CONTAINS is substring match on STRING facts, not list membership.
|
|
956
|
+
IN is the mirror of HAS_*: scalar fact, list value.
|
|
939
957
|
|
|
940
958
|
Action Types:
|
|
941
959
|
MUTATE_FACT, INCREMENT_FACT, EMIT_EVENT, BLOCK, EMIT_NOTIFICATION, EMIT_WEBHOOK, SET_FACT, ADD_TAG
|
|
@@ -1358,13 +1376,16 @@ function registerDeployCommands(program) {
|
|
|
1358
1376
|
"after",
|
|
1359
1377
|
dedent7`
|
|
1360
1378
|
|
|
1361
|
-
Lifecycle: Publish (DRAFT→ACTIVE) → Deploy (ACTIVE→LIVE) → Rollback / Undeploy
|
|
1379
|
+
Lifecycle: Publish (DRAFT→ACTIVE) → Deploy or Schedule (ACTIVE→LIVE) → Rollback / Undeploy
|
|
1362
1380
|
|
|
1363
1381
|
Commands:
|
|
1364
1382
|
publish Lock a DRAFT version (DRAFT → ACTIVE)
|
|
1365
1383
|
live Push an ACTIVE version to production traffic
|
|
1366
1384
|
rollback Revert to the previous deployed version
|
|
1367
1385
|
undeploy Remove the live version (stops all traffic)
|
|
1386
|
+
schedule Schedule an ACTIVE version to auto-deploy at its effective start
|
|
1387
|
+
unschedule Cancel the pending scheduled deployment
|
|
1388
|
+
schedules List scheduled deployments (all statuses)
|
|
1368
1389
|
history List deployment history with filters
|
|
1369
1390
|
detail Get deployment detail with integrity check
|
|
1370
1391
|
overview Show all groups' deployment status at a glance
|
|
@@ -1502,10 +1523,7 @@ function registerDeployCommands(program) {
|
|
|
1502
1523
|
process.exit(1);
|
|
1503
1524
|
}
|
|
1504
1525
|
});
|
|
1505
|
-
deploy.command("history").description("List deployment history").option("--group-id <groupId>", "Filter by policy group").option(
|
|
1506
|
-
"--types <types>",
|
|
1507
|
-
"Filter by types (comma-separated: PUBLISH,DEPLOY,ROLLBACK,UNDEPLOY)"
|
|
1508
|
-
).option("--start-date <date>", "Start date (yyyy-MM-dd)").option("--end-date <date>", "End date (yyyy-MM-dd)").option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").addHelpText(
|
|
1526
|
+
deploy.command("history").description("List deployment history").option("--group-id <groupId>", "Filter by policy group").option("--types <types>", "Filter by types (comma-separated: DEPLOY,ROLLBACK,UNDEPLOY)").option("--start-date <date>", "Start date (yyyy-MM-dd)").option("--end-date <date>", "End date (yyyy-MM-dd)").option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").addHelpText(
|
|
1509
1527
|
"after",
|
|
1510
1528
|
dedent7`
|
|
1511
1529
|
|
|
@@ -1660,6 +1678,119 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
|
|
|
1660
1678
|
process.exit(1);
|
|
1661
1679
|
}
|
|
1662
1680
|
});
|
|
1681
|
+
deploy.command("schedule").description("Schedule an ACTIVE version to auto-deploy at its effective start").requiredOption("--group-id <groupId>", "Policy group ID").requiredOption("--version-id <versionId>", "Version ID to schedule").requiredOption("--memo <memo>", "Schedule memo").addHelpText(
|
|
1682
|
+
"after",
|
|
1683
|
+
dedent7`
|
|
1684
|
+
|
|
1685
|
+
The version must be ACTIVE with a future effective start date; the system
|
|
1686
|
+
deploys it automatically at that time (within one scheduler tick, ≤60s).
|
|
1687
|
+
One pending schedule per group. Manual deploy/rollback/undeploy, starting
|
|
1688
|
+
an A/B test, or archiving the group cancels the pending schedule.
|
|
1689
|
+
|
|
1690
|
+
Example:
|
|
1691
|
+
$ lexq deploy schedule --group-id <gid> --version-id <vid> --memo "Q4 pricing"
|
|
1692
|
+
`
|
|
1693
|
+
).action(async (opts) => {
|
|
1694
|
+
try {
|
|
1695
|
+
const globalOpts = program.opts();
|
|
1696
|
+
const data = await apiRequest(
|
|
1697
|
+
"POST",
|
|
1698
|
+
`policy-groups/${opts.groupId}/schedule`,
|
|
1699
|
+
{
|
|
1700
|
+
apiKey: globalOpts.apiKey,
|
|
1701
|
+
baseUrl: globalOpts.baseUrl,
|
|
1702
|
+
dryRun: globalOpts.dryRun,
|
|
1703
|
+
verbose: globalOpts.verbose,
|
|
1704
|
+
body: { versionId: opts.versionId, memo: opts.memo }
|
|
1705
|
+
}
|
|
1706
|
+
);
|
|
1707
|
+
console.log(`\u2713 Scheduled v${data.versionNo ?? "?"} for ${data.scheduledFor}.`);
|
|
1708
|
+
} catch (error) {
|
|
1709
|
+
printError(error);
|
|
1710
|
+
process.exit(1);
|
|
1711
|
+
}
|
|
1712
|
+
});
|
|
1713
|
+
deploy.command("unschedule").description("Cancel the pending scheduled deployment for a group").requiredOption("--group-id <groupId>", "Policy group ID").option("--force", "Skip confirmation prompt").addHelpText(
|
|
1714
|
+
"after",
|
|
1715
|
+
dedent7`
|
|
1716
|
+
|
|
1717
|
+
Cancels the PENDING schedule only — the version itself is not affected.
|
|
1718
|
+
|
|
1719
|
+
Example:
|
|
1720
|
+
$ lexq deploy unschedule --group-id <gid> --force
|
|
1721
|
+
`
|
|
1722
|
+
).action(async (opts) => {
|
|
1723
|
+
try {
|
|
1724
|
+
const globalOpts = program.opts();
|
|
1725
|
+
if (!opts.force) {
|
|
1726
|
+
const { createInterface: createInterface2 } = await import("readline/promises");
|
|
1727
|
+
const rl = createInterface2({ input: process.stdin, output: process.stdout });
|
|
1728
|
+
const answer = await rl.question(
|
|
1729
|
+
`Cancel the pending scheduled deployment for group ${opts.groupId}? [y/N] `
|
|
1730
|
+
);
|
|
1731
|
+
rl.close();
|
|
1732
|
+
if (answer.toLowerCase() !== "y") {
|
|
1733
|
+
console.log("Canceled.");
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
}
|
|
1737
|
+
await apiRequest("DELETE", `policy-groups/${opts.groupId}/schedule`, {
|
|
1738
|
+
apiKey: globalOpts.apiKey,
|
|
1739
|
+
baseUrl: globalOpts.baseUrl,
|
|
1740
|
+
dryRun: globalOpts.dryRun,
|
|
1741
|
+
verbose: globalOpts.verbose
|
|
1742
|
+
});
|
|
1743
|
+
console.log(`\u2713 Scheduled deployment canceled for group ${opts.groupId}.`);
|
|
1744
|
+
} catch (error) {
|
|
1745
|
+
printError(error);
|
|
1746
|
+
process.exit(1);
|
|
1747
|
+
}
|
|
1748
|
+
});
|
|
1749
|
+
deploy.command("schedules").description("List scheduled deployments (all statuses, newest first)").option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").addHelpText(
|
|
1750
|
+
"after",
|
|
1751
|
+
dedent7`
|
|
1752
|
+
|
|
1753
|
+
Example:
|
|
1754
|
+
$ lexq deploy schedules --format table
|
|
1755
|
+
`
|
|
1756
|
+
).action(async (opts) => {
|
|
1757
|
+
try {
|
|
1758
|
+
const globalOpts = program.opts();
|
|
1759
|
+
const format = globalOpts.format ?? "json";
|
|
1760
|
+
const data = await apiRequest(
|
|
1761
|
+
"GET",
|
|
1762
|
+
"policy-groups/schedules",
|
|
1763
|
+
{
|
|
1764
|
+
apiKey: globalOpts.apiKey,
|
|
1765
|
+
baseUrl: globalOpts.baseUrl,
|
|
1766
|
+
dryRun: globalOpts.dryRun,
|
|
1767
|
+
verbose: globalOpts.verbose,
|
|
1768
|
+
params: { page: opts.page, size: opts.size }
|
|
1769
|
+
}
|
|
1770
|
+
);
|
|
1771
|
+
if (format === "table") {
|
|
1772
|
+
printTable(
|
|
1773
|
+
["Status", "Group", "Version", "Scheduled For", "By", "Result"],
|
|
1774
|
+
data.content.map((s) => [
|
|
1775
|
+
s.status,
|
|
1776
|
+
s.policyGroupName ?? s.policyGroupId.substring(0, 8),
|
|
1777
|
+
s.versionNo != null ? `v${s.versionNo}` : "\u2013",
|
|
1778
|
+
s.scheduledFor.substring(0, 16),
|
|
1779
|
+
s.scheduledByName,
|
|
1780
|
+
s.status === "EXECUTED" ? s.executedAt?.substring(0, 16) ?? "\u2013" : s.status === "CANCELED" ? s.canceledReason ?? "\u2013" : s.status === "FAILED" ? s.failedReason ?? "\u2013" : "\u2013"
|
|
1781
|
+
]),
|
|
1782
|
+
{ truncate: 20 }
|
|
1783
|
+
);
|
|
1784
|
+
console.log(`
|
|
1785
|
+
${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
|
|
1786
|
+
} else {
|
|
1787
|
+
printJson(data);
|
|
1788
|
+
}
|
|
1789
|
+
} catch (error) {
|
|
1790
|
+
printError(error);
|
|
1791
|
+
process.exit(1);
|
|
1792
|
+
}
|
|
1793
|
+
});
|
|
1663
1794
|
}
|
|
1664
1795
|
async function warnUnregisteredFacts(globalOpts, groupId, versionId) {
|
|
1665
1796
|
if (globalOpts.dryRun) return;
|
|
@@ -2206,13 +2337,16 @@ var TaskType = [
|
|
|
2206
2337
|
// Internal
|
|
2207
2338
|
"IMAGE_PROCESSING",
|
|
2208
2339
|
"DAILY_SETTLEMENT",
|
|
2209
|
-
"PLATFORM_WEBHOOK"
|
|
2340
|
+
"PLATFORM_WEBHOOK",
|
|
2341
|
+
"SCHEDULED_DEPLOYMENT"
|
|
2210
2342
|
];
|
|
2211
2343
|
var PlatformEventType = [
|
|
2212
2344
|
"VERSION_PUBLISHED",
|
|
2213
2345
|
"DEPLOYED",
|
|
2214
2346
|
"ROLLED_BACK",
|
|
2215
|
-
"UNDEPLOYED"
|
|
2347
|
+
"UNDEPLOYED",
|
|
2348
|
+
"DEPLOY_SCHEDULED",
|
|
2349
|
+
"DEPLOY_SCHEDULE_CANCELED"
|
|
2216
2350
|
];
|
|
2217
2351
|
var WebhookPayloadFormat = ["GENERIC", "SLACK"];
|
|
2218
2352
|
|
|
@@ -3596,8 +3730,23 @@ function registerRuleTools(server, callApi) {
|
|
|
3596
3730
|
defined (non-blocking, version-wide) — use it to decide what to register.
|
|
3597
3731
|
|
|
3598
3732
|
Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: "AND"|"OR", children: [...] }
|
|
3599
|
-
Operators: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, CONTAINS, IN, NOT_IN
|
|
3600
3733
|
Value types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER
|
|
3734
|
+
|
|
3735
|
+
Operators are constrained by the LEFT fact's type (from lexq_facts_list). Using one outside
|
|
3736
|
+
its type is rejected by the server — check the fact type before choosing an operator.
|
|
3737
|
+
- STRING fact: EQUALS, NOT_EQUALS, CONTAINS, IN, NOT_IN
|
|
3738
|
+
- NUMBER fact: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
|
|
3739
|
+
- BOOLEAN fact: EQUALS, NOT_EQUALS
|
|
3740
|
+
- LIST_* fact: HAS_ANY, HAS_ALL, HAS_NONE (only these)
|
|
3741
|
+
|
|
3742
|
+
HAS_* query list-typed facts. Value is always an array whose element type matches the fact:
|
|
3743
|
+
- HAS_ANY: fact has at least one of the given values
|
|
3744
|
+
- HAS_ALL: fact has all of the given values
|
|
3745
|
+
- HAS_NONE: fact has none of the given values
|
|
3746
|
+
Example: { "type": "SINGLE", "field": "user_tags", "operator": "HAS_ANY", "value": ["VIP","GOLD"], "valueType": "LIST_STRING" }
|
|
3747
|
+
|
|
3748
|
+
Do NOT use CONTAINS on a list fact — CONTAINS is substring match on STRING facts only.
|
|
3749
|
+
IN is the mirror of HAS_*: IN takes a scalar fact with a list value; HAS_* takes lists on both sides.
|
|
3601
3750
|
|
|
3602
3751
|
Actions: [{ type, parameters }]
|
|
3603
3752
|
|
|
@@ -3606,14 +3755,14 @@ function registerRuleTools(server, callApi) {
|
|
|
3606
3755
|
- INCREMENT_FACT: { targetVar: string, method: "PERCENTAGE"|"AMOUNT", refVar?: string (required when PERCENTAGE), rate?: number (when PERCENTAGE), value?: number (when AMOUNT), rounding?: RoundingOption } targetVar (accumulation target) must exist at execution; refVar (PERCENTAGE source) must exist when method is PERCENTAGE. Each is supplied as an input fact or written by a prior action in this rule — a missing required fact throws (no 0 default). Note: external system call (e.g. point system sync) is NOT a primitive responsibility. Compose [INCREMENT_FACT, EMIT_EVENT] chain instead.
|
|
3607
3756
|
- EMIT_EVENT: { integrationId: uuid, eventPayload: object (Map<string,unknown>, ≥1 entry) } eventPayload is passed through to the integration provider as-is. Domain-specific keys (couponId, ticketId, etc.) are routed by the provider, not validated by the engine.
|
|
3608
3757
|
- BLOCK: { reason: string }
|
|
3609
|
-
- EMIT_NOTIFICATION: { integrationId: uuid, targetVar: string, notificationPayload: object (Map<string,unknown>, ≥1 entry) } targetVar identifies the recipient fact (e.g. phone_number / email / device_token).
|
|
3758
|
+
- EMIT_NOTIFICATION: { integrationId: uuid, targetVar: string, notificationPayload: object (Map<string,unknown>, ≥1 entry) } targetVar identifies the recipient fact (e.g. phone_number / email / device_token) and is REQUIRED — the named fact must be present in the request or the action throws. (Contrast with ADD_TAG, where targetVar is an optional write target that is created if absent.)
|
|
3610
3759
|
- EMIT_WEBHOOK: { url: string, method: "POST", payloadTemplate?: object } payloadTemplate is optional. Without it, all facts are sent as-is. With it, the object is sent as the HTTP body with {{variables}} replaced at execution time. Variables: {{fact.xxx}}, {{output.xxx}}, {{timestamp}}, {{ruleName}}, {{groupName}}, {{versionNo}}, {{xxx}} (shorthand).
|
|
3611
3760
|
Platform examples:
|
|
3612
3761
|
Slack: { "text": "Rule {{ruleName}} fired — {{fact.customer_tier}}" }
|
|
3613
3762
|
Discord: { "content": "Rule {{ruleName}} fired — {{fact.customer_tier}}" }
|
|
3614
3763
|
Generic: { "event": "rule_matched", "rule": "{{ruleName}}", "amount": "{{output.payment_amount}}" }
|
|
3615
3764
|
- SET_FACT: { key: string, value: string|number|boolean }
|
|
3616
|
-
- ADD_TAG: { tag: string, targetVar
|
|
3765
|
+
- ADD_TAG: { tag: string, targetVar?: string (defaults to "user_tags") } Appends tag to a LIST_STRING fact, creating it if absent. Adding an existing tag is a no-op (idempotent). Read tags back with HAS_ANY / HAS_ALL / HAS_NONE.
|
|
3617
3766
|
|
|
3618
3767
|
RoundingOption (optional, MUTATE_FACT / INCREMENT_FACT only): { scale: integer (0..16), mode?: "HALF_UP"|"HALF_DOWN"|"HALF_EVEN"|"FLOOR"|"CEILING"|"DOWN"|"UP" } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
|
|
3619
3768
|
`,
|
|
@@ -3817,7 +3966,7 @@ function registerDeployTools(server, callApi) {
|
|
|
3817
3966
|
"lexq_deploy_live",
|
|
3818
3967
|
{
|
|
3819
3968
|
title: "Deploy to Live",
|
|
3820
|
-
description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately. Undefined facts do not block deployment (INV-4); use lexq_facts_unregistered to review what the version references but has not defined.",
|
|
3969
|
+
description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately. Versions whose effective start date has not arrived are rejected (P-037) \u2014 use lexq_deploy_schedule for those. Undefined facts do not block deployment (INV-4); use lexq_facts_unregistered to review what the version references but has not defined.",
|
|
3821
3970
|
inputSchema: {
|
|
3822
3971
|
groupId: z5.string().uuid().describe("Policy group ID"),
|
|
3823
3972
|
versionId: z5.string().uuid().describe("Version ID to deploy"),
|
|
@@ -3856,6 +4005,44 @@ function registerDeployTools(server, callApi) {
|
|
|
3856
4005
|
body: { memo }
|
|
3857
4006
|
})
|
|
3858
4007
|
);
|
|
4008
|
+
server.registerTool(
|
|
4009
|
+
"lexq_deploy_schedule",
|
|
4010
|
+
{
|
|
4011
|
+
title: "Schedule Deployment",
|
|
4012
|
+
description: "Schedule an ACTIVE version with a future effective start date to auto-deploy at that time (Scheduled Deployment). One pending schedule per group; manual deploy/rollback/undeploy, starting an A/B test, or archiving the group cancels it. The snapshot hash is sealed at scheduling and re-verified at execution (fail-closed).",
|
|
4013
|
+
inputSchema: {
|
|
4014
|
+
groupId: z5.string().uuid().describe("Policy group ID"),
|
|
4015
|
+
versionId: z5.string().uuid().describe("ACTIVE version ID with a future effective start date"),
|
|
4016
|
+
memo: z5.string().min(1).describe("Schedule memo (required)")
|
|
4017
|
+
}
|
|
4018
|
+
},
|
|
4019
|
+
async ({ groupId, versionId, memo }) => callApi("POST", `policy-groups/${groupId}/schedule`, {
|
|
4020
|
+
body: { versionId, memo }
|
|
4021
|
+
})
|
|
4022
|
+
);
|
|
4023
|
+
server.registerTool(
|
|
4024
|
+
"lexq_deploy_unschedule",
|
|
4025
|
+
{
|
|
4026
|
+
title: "Cancel Scheduled Deployment",
|
|
4027
|
+
description: "Cancel the pending scheduled deployment for a group. The version itself is not affected. Fails with P-039 if no pending schedule exists.",
|
|
4028
|
+
inputSchema: {
|
|
4029
|
+
groupId: z5.string().uuid().describe("Policy group ID")
|
|
4030
|
+
}
|
|
4031
|
+
},
|
|
4032
|
+
async ({ groupId }) => callApi("DELETE", `policy-groups/${groupId}/schedule`)
|
|
4033
|
+
);
|
|
4034
|
+
server.registerTool(
|
|
4035
|
+
"lexq_deploy_schedules",
|
|
4036
|
+
{
|
|
4037
|
+
title: "List Scheduled Deployments",
|
|
4038
|
+
description: "List scheduled deployments across all groups (all statuses: PENDING, EXECUTED, CANCELED, FAILED), newest first.",
|
|
4039
|
+
inputSchema: {
|
|
4040
|
+
page: z5.number().int().min(0).default(0).describe("Page number"),
|
|
4041
|
+
size: z5.number().int().min(1).max(100).default(20).describe("Page size")
|
|
4042
|
+
}
|
|
4043
|
+
},
|
|
4044
|
+
async ({ page, size }) => callApi("GET", "policy-groups/schedules", { params: paginationParams(page, size) })
|
|
4045
|
+
);
|
|
3859
4046
|
server.registerTool(
|
|
3860
4047
|
"lexq_deploy_history",
|
|
3861
4048
|
{
|
|
@@ -3865,9 +4052,7 @@ function registerDeployTools(server, callApi) {
|
|
|
3865
4052
|
page: z5.number().int().min(0).default(0).describe("Page number"),
|
|
3866
4053
|
size: z5.number().int().min(1).max(100).default(20).describe("Page size"),
|
|
3867
4054
|
groupId: z5.string().uuid().optional().describe("Filter by group ID"),
|
|
3868
|
-
types: z5.string().optional().describe(
|
|
3869
|
-
"Filter by deployment types (comma-separated: PUBLISH,DEPLOY,ROLLBACK,UNDEPLOY)"
|
|
3870
|
-
),
|
|
4055
|
+
types: z5.string().optional().describe("Filter by deployment types (comma-separated: DEPLOY,ROLLBACK,UNDEPLOY)"),
|
|
3871
4056
|
startDate: z5.string().optional().describe("Start date (yyyy-MM-dd)"),
|
|
3872
4057
|
endDate: z5.string().optional().describe("End date (yyyy-MM-dd)")
|
|
3873
4058
|
}
|
package/dist/mcp/register.d.ts
CHANGED
|
@@ -35,6 +35,12 @@ declare function paginationParams(page?: number, size?: number): Record<string,
|
|
|
35
35
|
*/
|
|
36
36
|
declare function formatUnregisteredFactWarning(meta: unknown): string | null;
|
|
37
37
|
|
|
38
|
+
declare class ApiError extends Error {
|
|
39
|
+
readonly statusCode: number;
|
|
40
|
+
readonly errorCode: string | null;
|
|
41
|
+
constructor(statusCode: number, errorCode: string | null, message: string);
|
|
42
|
+
}
|
|
43
|
+
|
|
38
44
|
/**
|
|
39
45
|
* Registers all MCP tools on the given server.
|
|
40
46
|
*
|
|
@@ -43,4 +49,4 @@ declare function formatUnregisteredFactWarning(meta: unknown): string | null;
|
|
|
43
49
|
*/
|
|
44
50
|
declare function registerAllTools(server: McpServer, callApi: CallApi): void;
|
|
45
51
|
|
|
46
|
-
export { type CallApi, type McpToolResult, formatUnregisteredFactWarning, paginationParams, registerAllTools };
|
|
52
|
+
export { ApiError, type CallApi, type McpToolResult, formatUnregisteredFactWarning, paginationParams, registerAllTools };
|
package/dist/mcp/register.js
CHANGED
|
@@ -154,6 +154,18 @@ import { join } from "path";
|
|
|
154
154
|
var CONFIG_DIR = join(homedir(), ".lexq");
|
|
155
155
|
var CONFIG_FILE = join(CONFIG_DIR, "config.json");
|
|
156
156
|
|
|
157
|
+
// src/lib/api-client.ts
|
|
158
|
+
var ApiError = class extends Error {
|
|
159
|
+
constructor(statusCode, errorCode, message) {
|
|
160
|
+
super(message);
|
|
161
|
+
this.statusCode = statusCode;
|
|
162
|
+
this.errorCode = errorCode;
|
|
163
|
+
this.name = "ApiError";
|
|
164
|
+
}
|
|
165
|
+
statusCode;
|
|
166
|
+
errorCode;
|
|
167
|
+
};
|
|
168
|
+
|
|
157
169
|
// src/mcp/tools/_shared.ts
|
|
158
170
|
function paginationParams(page, size) {
|
|
159
171
|
const params = {};
|
|
@@ -311,8 +323,23 @@ function registerRuleTools(server, callApi) {
|
|
|
311
323
|
defined (non-blocking, version-wide) — use it to decide what to register.
|
|
312
324
|
|
|
313
325
|
Condition: { type: "SINGLE", field, operator, value, valueType } or { type: "GROUP", operator: "AND"|"OR", children: [...] }
|
|
314
|
-
Operators: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, CONTAINS, IN, NOT_IN
|
|
315
326
|
Value types: STRING, NUMBER, BOOLEAN, LIST_STRING, LIST_NUMBER
|
|
327
|
+
|
|
328
|
+
Operators are constrained by the LEFT fact's type (from lexq_facts_list). Using one outside
|
|
329
|
+
its type is rejected by the server — check the fact type before choosing an operator.
|
|
330
|
+
- STRING fact: EQUALS, NOT_EQUALS, CONTAINS, IN, NOT_IN
|
|
331
|
+
- NUMBER fact: EQUALS, NOT_EQUALS, GREATER_THAN, GREATER_THAN_OR_EQUAL, LESS_THAN, LESS_THAN_OR_EQUAL, IN, NOT_IN
|
|
332
|
+
- BOOLEAN fact: EQUALS, NOT_EQUALS
|
|
333
|
+
- LIST_* fact: HAS_ANY, HAS_ALL, HAS_NONE (only these)
|
|
334
|
+
|
|
335
|
+
HAS_* query list-typed facts. Value is always an array whose element type matches the fact:
|
|
336
|
+
- HAS_ANY: fact has at least one of the given values
|
|
337
|
+
- HAS_ALL: fact has all of the given values
|
|
338
|
+
- HAS_NONE: fact has none of the given values
|
|
339
|
+
Example: { "type": "SINGLE", "field": "user_tags", "operator": "HAS_ANY", "value": ["VIP","GOLD"], "valueType": "LIST_STRING" }
|
|
340
|
+
|
|
341
|
+
Do NOT use CONTAINS on a list fact — CONTAINS is substring match on STRING facts only.
|
|
342
|
+
IN is the mirror of HAS_*: IN takes a scalar fact with a list value; HAS_* takes lists on both sides.
|
|
316
343
|
|
|
317
344
|
Actions: [{ type, parameters }]
|
|
318
345
|
|
|
@@ -321,14 +348,14 @@ function registerRuleTools(server, callApi) {
|
|
|
321
348
|
- INCREMENT_FACT: { targetVar: string, method: "PERCENTAGE"|"AMOUNT", refVar?: string (required when PERCENTAGE), rate?: number (when PERCENTAGE), value?: number (when AMOUNT), rounding?: RoundingOption } targetVar (accumulation target) must exist at execution; refVar (PERCENTAGE source) must exist when method is PERCENTAGE. Each is supplied as an input fact or written by a prior action in this rule — a missing required fact throws (no 0 default). Note: external system call (e.g. point system sync) is NOT a primitive responsibility. Compose [INCREMENT_FACT, EMIT_EVENT] chain instead.
|
|
322
349
|
- EMIT_EVENT: { integrationId: uuid, eventPayload: object (Map<string,unknown>, ≥1 entry) } eventPayload is passed through to the integration provider as-is. Domain-specific keys (couponId, ticketId, etc.) are routed by the provider, not validated by the engine.
|
|
323
350
|
- BLOCK: { reason: string }
|
|
324
|
-
- EMIT_NOTIFICATION: { integrationId: uuid, targetVar: string, notificationPayload: object (Map<string,unknown>, ≥1 entry) } targetVar identifies the recipient fact (e.g. phone_number / email / device_token).
|
|
351
|
+
- EMIT_NOTIFICATION: { integrationId: uuid, targetVar: string, notificationPayload: object (Map<string,unknown>, ≥1 entry) } targetVar identifies the recipient fact (e.g. phone_number / email / device_token) and is REQUIRED — the named fact must be present in the request or the action throws. (Contrast with ADD_TAG, where targetVar is an optional write target that is created if absent.)
|
|
325
352
|
- EMIT_WEBHOOK: { url: string, method: "POST", payloadTemplate?: object } payloadTemplate is optional. Without it, all facts are sent as-is. With it, the object is sent as the HTTP body with {{variables}} replaced at execution time. Variables: {{fact.xxx}}, {{output.xxx}}, {{timestamp}}, {{ruleName}}, {{groupName}}, {{versionNo}}, {{xxx}} (shorthand).
|
|
326
353
|
Platform examples:
|
|
327
354
|
Slack: { "text": "Rule {{ruleName}} fired — {{fact.customer_tier}}" }
|
|
328
355
|
Discord: { "content": "Rule {{ruleName}} fired — {{fact.customer_tier}}" }
|
|
329
356
|
Generic: { "event": "rule_matched", "rule": "{{ruleName}}", "amount": "{{output.payment_amount}}" }
|
|
330
357
|
- SET_FACT: { key: string, value: string|number|boolean }
|
|
331
|
-
- ADD_TAG: { tag: string, targetVar
|
|
358
|
+
- ADD_TAG: { tag: string, targetVar?: string (defaults to "user_tags") } Appends tag to a LIST_STRING fact, creating it if absent. Adding an existing tag is a no-op (idempotent). Read tags back with HAS_ANY / HAS_ALL / HAS_NONE.
|
|
332
359
|
|
|
333
360
|
RoundingOption (optional, MUTATE_FACT / INCREMENT_FACT only): { scale: integer (0..16), mode?: "HALF_UP"|"HALF_DOWN"|"HALF_EVEN"|"FLOOR"|"CEILING"|"DOWN"|"UP" } mode defaults to HALF_UP. When omitted, calculator output is preserved at full precision (lossless).
|
|
334
361
|
`,
|
|
@@ -532,7 +559,7 @@ function registerDeployTools(server, callApi) {
|
|
|
532
559
|
"lexq_deploy_live",
|
|
533
560
|
{
|
|
534
561
|
title: "Deploy to Live",
|
|
535
|
-
description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately. Undefined facts do not block deployment (INV-4); use lexq_facts_unregistered to review what the version references but has not defined.",
|
|
562
|
+
description: "Deploy an ACTIVE (published) version to live traffic. Takes effect immediately. Versions whose effective start date has not arrived are rejected (P-037) \u2014 use lexq_deploy_schedule for those. Undefined facts do not block deployment (INV-4); use lexq_facts_unregistered to review what the version references but has not defined.",
|
|
536
563
|
inputSchema: {
|
|
537
564
|
groupId: z5.string().uuid().describe("Policy group ID"),
|
|
538
565
|
versionId: z5.string().uuid().describe("Version ID to deploy"),
|
|
@@ -571,6 +598,44 @@ function registerDeployTools(server, callApi) {
|
|
|
571
598
|
body: { memo }
|
|
572
599
|
})
|
|
573
600
|
);
|
|
601
|
+
server.registerTool(
|
|
602
|
+
"lexq_deploy_schedule",
|
|
603
|
+
{
|
|
604
|
+
title: "Schedule Deployment",
|
|
605
|
+
description: "Schedule an ACTIVE version with a future effective start date to auto-deploy at that time (Scheduled Deployment). One pending schedule per group; manual deploy/rollback/undeploy, starting an A/B test, or archiving the group cancels it. The snapshot hash is sealed at scheduling and re-verified at execution (fail-closed).",
|
|
606
|
+
inputSchema: {
|
|
607
|
+
groupId: z5.string().uuid().describe("Policy group ID"),
|
|
608
|
+
versionId: z5.string().uuid().describe("ACTIVE version ID with a future effective start date"),
|
|
609
|
+
memo: z5.string().min(1).describe("Schedule memo (required)")
|
|
610
|
+
}
|
|
611
|
+
},
|
|
612
|
+
async ({ groupId, versionId, memo }) => callApi("POST", `policy-groups/${groupId}/schedule`, {
|
|
613
|
+
body: { versionId, memo }
|
|
614
|
+
})
|
|
615
|
+
);
|
|
616
|
+
server.registerTool(
|
|
617
|
+
"lexq_deploy_unschedule",
|
|
618
|
+
{
|
|
619
|
+
title: "Cancel Scheduled Deployment",
|
|
620
|
+
description: "Cancel the pending scheduled deployment for a group. The version itself is not affected. Fails with P-039 if no pending schedule exists.",
|
|
621
|
+
inputSchema: {
|
|
622
|
+
groupId: z5.string().uuid().describe("Policy group ID")
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
async ({ groupId }) => callApi("DELETE", `policy-groups/${groupId}/schedule`)
|
|
626
|
+
);
|
|
627
|
+
server.registerTool(
|
|
628
|
+
"lexq_deploy_schedules",
|
|
629
|
+
{
|
|
630
|
+
title: "List Scheduled Deployments",
|
|
631
|
+
description: "List scheduled deployments across all groups (all statuses: PENDING, EXECUTED, CANCELED, FAILED), newest first.",
|
|
632
|
+
inputSchema: {
|
|
633
|
+
page: z5.number().int().min(0).default(0).describe("Page number"),
|
|
634
|
+
size: z5.number().int().min(1).max(100).default(20).describe("Page size")
|
|
635
|
+
}
|
|
636
|
+
},
|
|
637
|
+
async ({ page, size }) => callApi("GET", "policy-groups/schedules", { params: paginationParams(page, size) })
|
|
638
|
+
);
|
|
574
639
|
server.registerTool(
|
|
575
640
|
"lexq_deploy_history",
|
|
576
641
|
{
|
|
@@ -580,9 +645,7 @@ function registerDeployTools(server, callApi) {
|
|
|
580
645
|
page: z5.number().int().min(0).default(0).describe("Page number"),
|
|
581
646
|
size: z5.number().int().min(1).max(100).default(20).describe("Page size"),
|
|
582
647
|
groupId: z5.string().uuid().optional().describe("Filter by group ID"),
|
|
583
|
-
types: z5.string().optional().describe(
|
|
584
|
-
"Filter by deployment types (comma-separated: PUBLISH,DEPLOY,ROLLBACK,UNDEPLOY)"
|
|
585
|
-
),
|
|
648
|
+
types: z5.string().optional().describe("Filter by deployment types (comma-separated: DEPLOY,ROLLBACK,UNDEPLOY)"),
|
|
586
649
|
startDate: z5.string().optional().describe("Start date (yyyy-MM-dd)"),
|
|
587
650
|
endDate: z5.string().optional().describe("End date (yyyy-MM-dd)")
|
|
588
651
|
}
|
|
@@ -1182,13 +1245,16 @@ var TaskType = [
|
|
|
1182
1245
|
// Internal
|
|
1183
1246
|
"IMAGE_PROCESSING",
|
|
1184
1247
|
"DAILY_SETTLEMENT",
|
|
1185
|
-
"PLATFORM_WEBHOOK"
|
|
1248
|
+
"PLATFORM_WEBHOOK",
|
|
1249
|
+
"SCHEDULED_DEPLOYMENT"
|
|
1186
1250
|
];
|
|
1187
1251
|
var PlatformEventType = [
|
|
1188
1252
|
"VERSION_PUBLISHED",
|
|
1189
1253
|
"DEPLOYED",
|
|
1190
1254
|
"ROLLED_BACK",
|
|
1191
|
-
"UNDEPLOYED"
|
|
1255
|
+
"UNDEPLOYED",
|
|
1256
|
+
"DEPLOY_SCHEDULED",
|
|
1257
|
+
"DEPLOY_SCHEDULE_CANCELED"
|
|
1192
1258
|
];
|
|
1193
1259
|
var WebhookPayloadFormat = ["GENERIC", "SLACK"];
|
|
1194
1260
|
|
|
@@ -1396,6 +1462,7 @@ function registerAllTools(server, callApi) {
|
|
|
1396
1462
|
registerWebhookSubscriptionTools(server, callApi);
|
|
1397
1463
|
}
|
|
1398
1464
|
export {
|
|
1465
|
+
ApiError,
|
|
1399
1466
|
formatUnregisteredFactWarning,
|
|
1400
1467
|
paginationParams,
|
|
1401
1468
|
registerAllTools
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lexq/cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.40",
|
|
4
4
|
"description": "LexQ CLI — manage policies, simulate rules, and deploy from the terminal. Built for humans and AI agents.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -29,7 +29,10 @@
|
|
|
29
29
|
"lint": "eslint src/",
|
|
30
30
|
"typecheck": "tsc --noEmit",
|
|
31
31
|
"start": "node dist/index.js",
|
|
32
|
-
"prepublishOnly": "pnpm build"
|
|
32
|
+
"prepublishOnly": "pnpm build",
|
|
33
|
+
"knip": "knip",
|
|
34
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
35
|
+
"format:check": "prettier --check \"src/**/*.ts\""
|
|
33
36
|
},
|
|
34
37
|
"keywords": [
|
|
35
38
|
"lexq",
|
|
@@ -57,15 +60,14 @@
|
|
|
57
60
|
"cli-table3": "^0.6.5",
|
|
58
61
|
"commander": "^13.1.0",
|
|
59
62
|
"dedent": "^1.7.2",
|
|
60
|
-
"ora": "^8.2.0",
|
|
61
|
-
"prettier": "^3.8.1",
|
|
62
63
|
"zod": "^3.25.76"
|
|
63
64
|
},
|
|
64
65
|
"devDependencies": {
|
|
65
66
|
"@eslint/js": "^9.20.0",
|
|
66
|
-
"@types/dedent": "^0.7.2",
|
|
67
67
|
"@types/node": "^22.12.0",
|
|
68
68
|
"eslint": "^10.2.0",
|
|
69
|
+
"knip": "^6.29.0",
|
|
70
|
+
"prettier": "^3.9.6",
|
|
69
71
|
"tsup": "^8.5.1",
|
|
70
72
|
"typescript": "^5.7.0",
|
|
71
73
|
"typescript-eslint": "^8.24.0"
|
|
@@ -256,7 +256,7 @@ lexq rules create --group-id <gid> --version-id <vid> --json '{
|
|
|
256
256
|
"type": "EMIT_NOTIFICATION",
|
|
257
257
|
"parameters": {
|
|
258
258
|
"integrationId": "<notification-integration-uuid>",
|
|
259
|
-
"
|
|
259
|
+
"targetVar": "user_id",
|
|
260
260
|
"notificationPayload": {
|
|
261
261
|
"channel": "PUSH",
|
|
262
262
|
"templateId": "welcome_points"
|
|
@@ -502,4 +502,73 @@ lexq analytics simulation start --json '{
|
|
|
502
502
|
|
|
503
503
|
# 6. If simulation passes, deploy
|
|
504
504
|
lexq deploy live --group-id <gid> --version-id <newVid> --memo "Migration complete"
|
|
505
|
-
```
|
|
505
|
+
```
|
|
506
|
+
|
|
507
|
+
---
|
|
508
|
+
|
|
509
|
+
## Recipe 11: Tag-Based Segmentation
|
|
510
|
+
|
|
511
|
+
**Goal:** Write tags in one rule, branch on them in another.
|
|
512
|
+
|
|
513
|
+
`user_tags` is a `LIST_STRING` fact seeded automatically for every tenant. `ADD_TAG` appends to
|
|
514
|
+
it; `HAS_ANY` / `HAS_ALL` / `HAS_NONE` read it.
|
|
515
|
+
|
|
516
|
+
```bash
|
|
517
|
+
# Tags are written by earlier rules (or supplied as input facts).
|
|
518
|
+
lexq rules create --group-id <gid> --version-id <vid> --json '{
|
|
519
|
+
"name": "Tag High-Value Customers",
|
|
520
|
+
"condition": {
|
|
521
|
+
"type": "SINGLE", "field": "lifetime_value", "operator": "GREATER_THAN_OR_EQUAL",
|
|
522
|
+
"value": 1000000, "valueType": "NUMBER"
|
|
523
|
+
},
|
|
524
|
+
"actions": [
|
|
525
|
+
{ "type": "ADD_TAG", "parameters": { "tag": "high_value" } }
|
|
526
|
+
]
|
|
527
|
+
}'
|
|
528
|
+
|
|
529
|
+
# Branch on any one of several tags
|
|
530
|
+
lexq rules create --group-id <gid> --version-id <vid> --json '{
|
|
531
|
+
"name": "Priority Support for VIP or High Value",
|
|
532
|
+
"condition": {
|
|
533
|
+
"type": "SINGLE", "field": "user_tags", "operator": "HAS_ANY",
|
|
534
|
+
"value": ["VIP", "high_value"], "valueType": "LIST_STRING"
|
|
535
|
+
},
|
|
536
|
+
"actions": [{ "type": "SET_FACT", "parameters": { "key": "support_tier", "value": "PRIORITY" } }]
|
|
537
|
+
}'
|
|
538
|
+
|
|
539
|
+
# Require every tag
|
|
540
|
+
lexq rules create --group-id <gid> --version-id <vid> --json '{
|
|
541
|
+
"name": "Beta Feature for Verified VIP",
|
|
542
|
+
"condition": {
|
|
543
|
+
"type": "SINGLE", "field": "user_tags", "operator": "HAS_ALL",
|
|
544
|
+
"value": ["VIP", "verified"], "valueType": "LIST_STRING"
|
|
545
|
+
},
|
|
546
|
+
"actions": [{ "type": "SET_FACT", "parameters": { "key": "beta_enabled", "value": true } }]
|
|
547
|
+
}'
|
|
548
|
+
|
|
549
|
+
# Exclude tagged users
|
|
550
|
+
lexq rules create --group-id <gid> --version-id <vid> --json '{
|
|
551
|
+
"name": "Promo Excludes Fraud Review",
|
|
552
|
+
"condition": {
|
|
553
|
+
"type": "SINGLE", "field": "user_tags", "operator": "HAS_NONE",
|
|
554
|
+
"value": ["fraud_review", "suspended"], "valueType": "LIST_STRING"
|
|
555
|
+
},
|
|
556
|
+
"actions": [{ "type": "MUTATE_FACT", "parameters": {
|
|
557
|
+
"refVar": "payment_amount", "method": "PERCENTAGE", "operator": "SUB", "rate": 5,
|
|
558
|
+
"rounding": { "mode": "HALF_UP", "scale": 0 }
|
|
559
|
+
}}]
|
|
560
|
+
}'
|
|
561
|
+
|
|
562
|
+
# Verify
|
|
563
|
+
lexq analytics dry-run --version-id <vid> --debug --mock --json '{
|
|
564
|
+
"facts": { "user_tags": ["VIP", "verified"], "payment_amount": 100000, "lifetime_value": 500000 }
|
|
565
|
+
}'
|
|
566
|
+
```
|
|
567
|
+
|
|
568
|
+
**Notes**
|
|
569
|
+
|
|
570
|
+
- The `value` is always an array, even for a single tag: `"value": ["VIP"]`.
|
|
571
|
+
- `ADD_TAG` writes to `user_tags` by default. Pass `targetVar` to append to a different `LIST_STRING` fact. The list is
|
|
572
|
+
created if absent, and re-adding an existing tag is a no-op.
|
|
573
|
+
- An empty array makes `HAS_ALL` and `HAS_NONE` always true — they never look at the fact.
|
|
574
|
+
- Rules fire in priority order, so a tag written by rule 0 is visible to rule 1.
|
|
@@ -70,17 +70,53 @@ Conditions use a tree structure with two node types: `SINGLE` and `GROUP`.
|
|
|
70
70
|
|
|
71
71
|
### Operators
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
|
77
|
-
|
|
78
|
-
| `
|
|
79
|
-
| `
|
|
80
|
-
| `
|
|
81
|
-
| `
|
|
82
|
-
|
|
83
|
-
|
|
|
73
|
+
Operators are constrained by the **left fact's type**. Using one outside its type is rejected
|
|
74
|
+
by the server — check `lexq facts list` before choosing.
|
|
75
|
+
|
|
76
|
+
| Fact type | Allowed operators |
|
|
77
|
+
|-------------------------------|--------------------------------------------------------------------------------------------------------------------|
|
|
78
|
+
| `STRING` | `EQUALS`, `NOT_EQUALS`, `CONTAINS`, `IN`, `NOT_IN` |
|
|
79
|
+
| `NUMBER` | `EQUALS`, `NOT_EQUALS`, `GREATER_THAN`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN`, `LESS_THAN_OR_EQUAL`, `IN`, `NOT_IN` |
|
|
80
|
+
| `BOOLEAN` | `EQUALS`, `NOT_EQUALS` |
|
|
81
|
+
| `LIST_STRING` / `LIST_NUMBER` | `HAS_ANY`, `HAS_ALL`, `HAS_NONE` |
|
|
82
|
+
|
|
83
|
+
| Operator | Description | `value` |
|
|
84
|
+
|-------------------------------------------------------------------------------|------------------------------------------------------------|---------|
|
|
85
|
+
| `EQUALS` / `NOT_EQUALS` | Exact match / negation | scalar |
|
|
86
|
+
| `GREATER_THAN` / `GREATER_THAN_OR_EQUAL` / `LESS_THAN` / `LESS_THAN_OR_EQUAL` | Numeric comparison | scalar |
|
|
87
|
+
| `CONTAINS` | **Substring** match on a STRING fact — not list membership | scalar |
|
|
88
|
+
| `IN` / `NOT_IN` | Scalar fact is (not) in the given list | array |
|
|
89
|
+
| `HAS_ANY` | List fact has **at least one** of the given values | array |
|
|
90
|
+
| `HAS_ALL` | List fact has **all** of the given values | array |
|
|
91
|
+
| `HAS_NONE` | List fact has **none** of the given values | array |
|
|
92
|
+
|
|
93
|
+
**`IN` vs `HAS_*` — mirrors of each other.** This is the most common mistake here:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
// scalar fact, list value
|
|
97
|
+
{
|
|
98
|
+
"field": "region",
|
|
99
|
+
"operator": "IN",
|
|
100
|
+
"value": [
|
|
101
|
+
"KR",
|
|
102
|
+
"JP"
|
|
103
|
+
],
|
|
104
|
+
"valueType": "LIST_STRING"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// list fact, list value
|
|
108
|
+
{
|
|
109
|
+
"field": "user_tags",
|
|
110
|
+
"operator": "HAS_ANY",
|
|
111
|
+
"value": [
|
|
112
|
+
"VIP",
|
|
113
|
+
"GOLD"
|
|
114
|
+
],
|
|
115
|
+
"valueType": "LIST_STRING"
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Do **not** use `CONTAINS` on a list fact — that idiom works in some rule engines but is rejected here.
|
|
84
120
|
|
|
85
121
|
### Value Types
|
|
86
122
|
|
|
@@ -145,10 +181,15 @@ Each rule can have multiple actions. Actions fire sequentially.
|
|
|
145
181
|
| `INCREMENT_FACT` | Increment a fact (cumulative add) | `targetVar`, `refVar`, `method`, `value` or `rate`, `rounding` |
|
|
146
182
|
| `EMIT_EVENT` | Emit an event to an external integration (coupons, etc.) | `integrationId`, `eventPayload` (Map) |
|
|
147
183
|
| `BLOCK` | Block the transaction | `reason`, `code` |
|
|
148
|
-
| `EMIT_NOTIFICATION` | Send a notification | `integrationId`, `
|
|
184
|
+
| `EMIT_NOTIFICATION` | Send a notification | `integrationId`, `targetVar`, `notificationPayload` (Map) |
|
|
149
185
|
| `EMIT_WEBHOOK` | Call an external URL | `url`, `payloadTemplate` |
|
|
150
186
|
| `SET_FACT` | Set a fact value (literal assignment) | `key`, `value` |
|
|
151
|
-
| `ADD_TAG` |
|
|
187
|
+
| `ADD_TAG` | Append a tag to a list fact | `tag`, `targetVar` (optional, default `user_tags`) |
|
|
188
|
+
|
|
189
|
+
**Two different `targetVar` meanings.** `EMIT_NOTIFICATION.targetVar` is a **read** — it names the
|
|
190
|
+
fact holding the recipient (`phone_number`, `email`, `device_token`), and the action throws if that
|
|
191
|
+
fact is absent from the request. `ADD_TAG.targetVar` is a **write** — the list is created if absent,
|
|
192
|
+
and adding a tag that is already present is a no-op.
|
|
152
193
|
|
|
153
194
|
### Action Example: 10% Discount via MUTATE_FACT
|
|
154
195
|
|
|
@@ -305,4 +346,5 @@ Before creating rules, always:
|
|
|
305
346
|
1. **Check available facts:** `lexq facts list`
|
|
306
347
|
2. **Confirm the version is DRAFT:** `lexq versions get --group-id <gid> --id <vid>` → status must be `DRAFT`
|
|
307
348
|
3. **Use exact fact keys** from the fact definitions (snake_case, case-sensitive)
|
|
308
|
-
4. **Match value types** — a fact defined as `NUMBER` must receive numeric values, not strings
|
|
349
|
+
4. **Match value types** — a fact defined as `NUMBER` must receive numeric values, not strings
|
|
350
|
+
5. **Match the operator to the fact type** — list-typed facts accept only `HAS_ANY` / `HAS_ALL` / `HAS_NONE`
|
|
@@ -111,7 +111,7 @@ lexq analytics dry-run --version-id <vid> --debug --mock --json '{
|
|
|
111
111
|
"ruleId": "...",
|
|
112
112
|
"ruleName": "VIP 10% Discount",
|
|
113
113
|
"matched": true,
|
|
114
|
-
"matchExpression": "(customer_tier == VIP
|
|
114
|
+
"matchExpression": "(customer_tier == 'VIP') && (payment_amount >= 100000)",
|
|
115
115
|
"generatedActions": [
|
|
116
116
|
{
|
|
117
117
|
"type": "MUTATE_FACT",
|
|
@@ -143,26 +143,46 @@ lexq analytics dry-run --version-id <vid> --debug --mock --json '{
|
|
|
143
143
|
|
|
144
144
|
### Reading Decision Traces
|
|
145
145
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
|
149
|
-
|
|
150
|
-
| `
|
|
151
|
-
| `
|
|
152
|
-
| `
|
|
153
|
-
| `
|
|
154
|
-
| `ERROR`
|
|
146
|
+
Each trace carries a `status` (what happened) and a `reasonCode` (why).
|
|
147
|
+
|
|
148
|
+
| Status | Meaning |
|
|
149
|
+
|----------------|----------------------------------------------------|
|
|
150
|
+
| `SELECTED` | Rule matched and its actions fired |
|
|
151
|
+
| `NO_MATCH` | Condition did not match, or could not be evaluated |
|
|
152
|
+
| `NOT_SELECTED` | Matched but excluded by conflict resolution |
|
|
153
|
+
| `BLOCKED` | Blocked by a mutex group or group activation limit |
|
|
154
|
+
| `ERROR` | Action execution failed |
|
|
155
155
|
|
|
156
156
|
### Reading Reason Codes
|
|
157
157
|
|
|
158
|
-
| Code
|
|
159
|
-
|
|
160
|
-
| `FINAL_WINNER`
|
|
161
|
-
| `CONDITION_MISMATCH`
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
164
|
-
| `
|
|
165
|
-
| `
|
|
158
|
+
| Code | Meaning |
|
|
159
|
+
|--------------------------|----------------------------------------------------------|
|
|
160
|
+
| `FINAL_WINNER` | Successfully executed |
|
|
161
|
+
| `CONDITION_MISMATCH` | Condition not satisfied, or could not be evaluated |
|
|
162
|
+
| `EFFECTIVE_DATE_INVALID` | Outside the version's effective date range |
|
|
163
|
+
| `MUTEX_PRIORITY_LOST` | Another rule in the same mutex group had higher priority |
|
|
164
|
+
| `MUTEX_LIMIT_REACHED` | Mutex group's max rules already fired |
|
|
165
|
+
| `GROUP_PRIORITY_LOST` | Another group in the same activation group won |
|
|
166
|
+
| `GROUP_LIMIT_REACHED` | Group's `executionLimit` reached |
|
|
167
|
+
| `ACTION_ERROR` | Action execution failed (e.g., webhook timeout) |
|
|
168
|
+
| `ENGINE_ERROR` | Internal engine failure |
|
|
169
|
+
|
|
170
|
+
#### `reasonDetail` on unevaluable conditions
|
|
171
|
+
|
|
172
|
+
`CONDITION_MISMATCH` covers two different things, distinguished by `reasonDetail`:
|
|
173
|
+
|
|
174
|
+
- **empty** — the condition was evaluated and did not match
|
|
175
|
+
- **`Evaluation error: <code>`** — the condition could not be evaluated at all
|
|
176
|
+
|
|
177
|
+
| Code | Meaning |
|
|
178
|
+
|-------------------------|--------------------------------------------------------------------|
|
|
179
|
+
| `FACT_NOT_PROVIDED` | The rule references a fact absent from the request |
|
|
180
|
+
| `FACT_TYPE_MISMATCH` | The fact's runtime type does not match the condition |
|
|
181
|
+
| `UNSUPPORTED_FACT_TYPE` | Operator not valid for the fact's type (e.g. `CONTAINS` on a list) |
|
|
182
|
+
| `MALFORMED_RULE` | The stored rule is structurally invalid |
|
|
183
|
+
|
|
184
|
+
A rule from another group referencing facts you did not send yields `FACT_NOT_PROVIDED` — this
|
|
185
|
+
is normal, not an error.
|
|
166
186
|
|
|
167
187
|
## 3. Dry Run Compare
|
|
168
188
|
|