@lexq/cli 1.1.0 → 1.1.2

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/AGENTS.md CHANGED
@@ -5,7 +5,7 @@
5
5
  ## What is this?
6
6
 
7
7
  LexQ CLI (`@lexq/cli`, binary: `lexq`) manages a policy execution engine. Policies are business rules (if-then) that
8
- evaluate input facts and produce actions (discounts, blocks, notifications, etc.).
8
+ evaluate input facts and produce actions: change a number such as a discount, set a fact, or record a rejection.
9
9
 
10
10
  The CLI also doubles as an **MCP server** — run `lexq serve --mcp` to expose the full command inventory as MCP tools to
11
11
  any MCP-compatible AI client.
@@ -106,7 +106,7 @@ lexq deploy live --group-id $GID --version-id $VID --memo "Initial deploy"
106
106
 
107
107
  ## Example: Subscribe to Deployment Notifications
108
108
 
109
- Webhook subscriptions fire on platform events — separate from rule-level webhooks.
109
+ Webhook subscriptions fire on deployment lifecycle events.
110
110
 
111
111
  ```bash
112
112
  # Subscribe to Slack for deployment events
package/CONTEXT.md CHANGED
@@ -64,11 +64,11 @@ Tenant
64
64
 
65
65
  ### Policy Group Status
66
66
 
67
- | Value | Description |
68
- |------------|------------------------------------|
69
- | `ACTIVE` | Normal operation |
70
- | `DISABLED` | Execution blocked (emergency stop) |
71
- | `ARCHIVED` | Permanently removed |
67
+ | Value | Description |
68
+ |------------|---------------------------------------------------------------------------------------------------------------------|
69
+ | `ACTIVE` | Normal operation |
70
+ | `DISABLED` | Production execution rejected (`P-009`); dry run, simulation, and replay still work |
71
+ | `ARCHIVED` | Deleted. Kept for audit if it was ever executed, deployed, or replayed; otherwise permanently deleted after 30 days |
72
72
 
73
73
  ### Policy Version Status
74
74
 
package/README.md CHANGED
@@ -48,7 +48,7 @@ Requires **Node.js 22+**.
48
48
  ```bash
49
49
  # 1. Authenticate
50
50
  lexq auth login
51
- # Enter your API key (create one at console.lexq.io → Management → API Keys)
51
+ # Enter your API key (create one with the Manage scope at console.lexq.io → Management → API Keys)
52
52
 
53
53
  # 2. Verify
54
54
  lexq auth whoami
package/dist/index.js CHANGED
@@ -163,6 +163,10 @@ import Table from "cli-table3";
163
163
  function printJson(data) {
164
164
  console.log(stringifyJson(data, 2));
165
165
  }
166
+ function groupLabel(name, archivedAt, fallback = "\u2013") {
167
+ const label = name ?? fallback;
168
+ return archivedAt ? `(deleted) ${label}` : label;
169
+ }
166
170
  function printTable(headers, rows, options) {
167
171
  const table = new Table({
168
172
  head: headers,
@@ -227,7 +231,8 @@ function registerAuthCommands(program) {
227
231
  whoami Verify authentication and show account info
228
232
 
229
233
  Getting Started:
230
- 1. Get your API key from the LexQ Console (Settings → API Keys)
234
+ 1. Create an API key with the Manage scope in the LexQ Console (Management → API Keys).
235
+ An "Execute only" key is for the execution API and does not work with this CLI.
231
236
  2. Run: lexq auth login
232
237
  3. Verify: lexq auth whoami
233
238
  `
@@ -269,7 +274,7 @@ function registerAuthCommands(program) {
269
274
 
270
275
  Example:
271
276
  $ lexq auth whoami
272
- { "tenantId": "abc-123", "userId": "...", "role": "ADMIN", "tenantTimezone": "Asia/Seoul", "apiKey": "lexq_us_****abcd" }
277
+ { "tenantId": "abc-123", "userId": "...", "role": "API_ADMIN", "tenantTimezone": "Asia/Seoul", "apiKey": "lexq****abcd", "baseUrl": "https://api.lexq.io/api/v1/partners" }
273
278
  `
274
279
  ).action(async () => {
275
280
  try {
@@ -310,7 +315,7 @@ function registerStatusCommand(program) {
310
315
 
311
316
  Example:
312
317
  $ lexq status
313
- { "status": "ok", "latencyMs": 142, "tenantId": "abc-123", "role": "ADMIN", "tenantTimezone": "Asia/Seoul" }
318
+ { "status": "ok", "latencyMs": 142, "tenantId": "abc-123", "role": "API_ADMIN", "tenantTimezone": "Asia/Seoul" }
314
319
 
315
320
  Use this to verify your API key is valid and the LexQ API is reachable.
316
321
  tenantTimezone is the zone every yyyy-MM-dd date option is read in.
@@ -342,6 +347,8 @@ var MAX_ROUNDING_SCALE = 34;
342
347
  var MIN_TRAFFIC_RATE = 1;
343
348
  var MAX_TRAFFIC_RATE = 99;
344
349
  var REPLAY_WINDOW_MAX_RECORDS = 5e4;
350
+ var DEFAULT_SIMULATION_RECORDS = 1e3;
351
+ var MAX_SIMULATION_RECORDS = 1e5;
345
352
  var PROFILE_SAMPLE_PERMILLE = 10;
346
353
  var LATENCY_WINDOW_MILLIS = 6e4;
347
354
  var TAIL_MIN_OBS = 3;
@@ -384,18 +391,21 @@ function registerGroupCommands(program) {
384
391
  It controls deployment lifecycle, conflict resolution, and A/B testing.
385
392
 
386
393
  Commands:
387
- list List all policy groups
394
+ list List policy groups (deleted groups are not included)
388
395
  get Get group detail by ID
389
396
  create Create a new group
390
397
  update Update group settings
391
- reorder Change group priorities (drag & drag equivalent)
398
+ reorder Change group priorities (drag & drop equivalent)
392
399
  delete Archive a group
393
400
  ab-test Manage A/B tests (start, stop, adjust)
394
401
 
395
- Statuses: ACTIVE, DISABLED (emergency stop), ARCHIVED (soft delete)
402
+ Statuses: ACTIVE, DISABLED, ARCHIVED
403
+ DISABLED stops production execution (403 P-009) and keeps the deployed version and any A/B test.
404
+ Dry run, simulation, and replay still work. Set ACTIVE to resume.
405
+ ARCHIVED means deleted. Only 'lexq groups delete' sets it, and it cannot be undone.
396
406
  `
397
407
  );
398
- groups.command("list").description("List all policy groups").action(async () => {
408
+ groups.command("list").description("List policy groups (deleted groups are not included)").action(async () => {
399
409
  try {
400
410
  const globalOpts = program.opts();
401
411
  const format = globalOpts.format ?? "json";
@@ -490,6 +500,7 @@ ${data.length} total`);
490
500
  $ lexq groups update --id <groupId> --json '{"description": "Updated", "status": "DISABLED"}'
491
501
 
492
502
  All fields are optional — only provided fields are updated. priority is not settable here — use 'lexq groups reorder'.
503
+ A deleted group cannot be updated (409 P-059).
493
504
  `
494
505
  ).action(async (opts) => {
495
506
  try {
@@ -512,7 +523,15 @@ ${data.length} total`);
512
523
  "after",
513
524
  dedent3`
514
525
 
515
- This archives the group (soft delete). Use --force to skip the confirmation prompt.
526
+ A deployed group cannot be deleted (P-020). Undeploy it first, which also ends a running A/B test.
527
+
528
+ A deleted group no longer appears in 'lexq groups list', and its name can be reused right away.
529
+ It cannot be restored, and calls that change it (settings, versions, rules, starting an A/B
530
+ test, publish, deploy, rollback, scheduling a deployment) return 409 P-059. If it was ever
531
+ executed, deployed, or replayed, it is kept for audit; otherwise it is permanently deleted
532
+ after 30 days.
533
+
534
+ Use --force to skip the confirmation prompt.
516
535
  `
517
536
  ).action(async (opts) => {
518
537
  try {
@@ -1328,7 +1347,9 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
1328
1347
  dedent6`
1329
1348
 
1330
1349
  Facts used in a version's rules that have no definition yet (read-only).
1331
- Does not block publish/deploy — register them to enable validation.
1350
+ Keys a rule only reads do not block publish/deploy; register them to enable
1351
+ validation. A SET_FACT or MUTATE_FACT targetVar must already be registered:
1352
+ saving or publishing fails with ACT-032 otherwise.
1332
1353
 
1333
1354
  Example:
1334
1355
  $ lexq facts unregistered --group-id <gid> --version-id <vid> --format table
@@ -1799,7 +1820,7 @@ function registerDeployCommands(program) {
1799
1820
  data.content.map((d) => [
1800
1821
  d.id.substring(0, 8),
1801
1822
  d.deploymentType,
1802
- d.policyGroupName,
1823
+ groupLabel(d.policyGroupName, d.policyGroupArchivedAt),
1803
1824
  d.versionNo != null ? `v${d.versionNo}` : "\u2013",
1804
1825
  d.deployedByName,
1805
1826
  utcMinute(d.deployedAt)
@@ -2022,7 +2043,11 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
2022
2043
  ["Status", "Group", "Version", "Scheduled For", "By", "Result"],
2023
2044
  data.content.map((s) => [
2024
2045
  s.status,
2025
- s.policyGroupName ?? s.policyGroupId.substring(0, 8),
2046
+ groupLabel(
2047
+ s.policyGroupName,
2048
+ s.policyGroupArchivedAt,
2049
+ s.policyGroupId.substring(0, 8)
2050
+ ),
2026
2051
  s.versionNo != null ? `v${s.versionNo}` : "\u2013",
2027
2052
  utcMinute(s.scheduledFor),
2028
2053
  s.scheduledByName,
@@ -2375,7 +2400,7 @@ function registerAnalyticsCommands(program) {
2375
2400
  ["ID", "Group", "Target", "Status", "Match", "Records", "Created"],
2376
2401
  data.content.map((s) => [
2377
2402
  s.simulationId.substring(0, 8),
2378
- s.policyGroupName,
2403
+ groupLabel(s.policyGroupName, s.policyGroupArchivedAt),
2379
2404
  s.targetVersionName ?? "\u2013",
2380
2405
  s.status,
2381
2406
  `${s.matchRate.toFixed(1)}%`,
@@ -2736,12 +2761,12 @@ function registerHistoryCommands(program) {
2736
2761
  ["Trace", "Group", "Version", "Status", "Matched", "Latency", "At"],
2737
2762
  data.content.map((h) => [
2738
2763
  h.traceId.substring(0, 12),
2739
- h.policyGroupName ?? "\u2013",
2764
+ groupLabel(h.policyGroupName, h.policyGroupArchivedAt),
2740
2765
  h.policyVersionNo != null ? `v${h.policyVersionNo}` : "\u2013",
2741
2766
  h.status,
2742
2767
  h.isMatched ? "\u2713" : "\u2717",
2743
2768
  `${h.latencyMs}ms`,
2744
- utcMinute(h.createdAt)
2769
+ utcMinute(h.executedAt)
2745
2770
  ]),
2746
2771
  { truncate: 20 }
2747
2772
  );
@@ -3250,8 +3275,8 @@ function registerWebhookSubscriptionCommands(program) {
3250
3275
  "after",
3251
3276
  dedent14`
3252
3277
 
3253
- Receive notifications when deployment lifecycle events occur
3254
- (publish, deploy, rollback, undeploy).
3278
+ Receive notifications for deployment lifecycle events: publish, deploy, rollback, and
3279
+ undeploy, plus a deployment being scheduled or a schedule being canceled.
3255
3280
 
3256
3281
  Commands:
3257
3282
  list List all webhook subscriptions
@@ -3260,8 +3285,8 @@ function registerWebhookSubscriptionCommands(program) {
3260
3285
  delete Delete a subscription
3261
3286
  test Send a test event to verify connectivity
3262
3287
 
3263
- This is separate from Integrations (rule action webhooks).
3264
- Webhook subscriptions are for platform-level event notifications.
3288
+ Rule actions never call external systems. These subscriptions are how LexQ pushes deployment
3289
+ events to you.
3265
3290
  `
3266
3291
  );
3267
3292
  webhooks.command("list").description("List webhook subscriptions").option("--page <number>", "Page number", "0").option("--size <number>", "Page size", "20").action(async (opts) => {
@@ -3346,7 +3371,7 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
3346
3371
  Fields:
3347
3372
  name string Subscription name (required, unique per tenant)
3348
3373
  webhookUrl string Webhook endpoint URL (required)
3349
- subscribedEvents string[] VERSION_PUBLISHED, DEPLOYED, ROLLED_BACK, UNDEPLOYED
3374
+ subscribedEvents string[] ${PlatformEventType.join(", ")}
3350
3375
  payloadFormat string GENERIC (full JSON) or SLACK ({"text":"..."}) [default: GENERIC]
3351
3376
  secret string HMAC-SHA256 signing secret (optional)
3352
3377
  isActive boolean Enable/disable (optional) [default: true]
@@ -3560,7 +3585,7 @@ function registerGroupTools(server, callApi) {
3560
3585
  {
3561
3586
  title: "List Policy Groups",
3562
3587
  annotations: { readOnlyHint: true },
3563
- description: "List all policy groups (tenant-wide, priority ASC).",
3588
+ description: "List policy groups (tenant-wide, priority ASC). Deleted groups are not included.",
3564
3589
  inputSchema: z2.object({})
3565
3590
  },
3566
3591
  async () => callApi("GET", "policy-groups")
@@ -3613,12 +3638,14 @@ function registerGroupTools(server, callApi) {
3613
3638
  {
3614
3639
  title: "Update Policy Group",
3615
3640
  annotations: { readOnlyHint: false, destructiveHint: true },
3616
- description: "Update a policy group. Only provided fields are updated; omitted fields remain unchanged.",
3641
+ description: "Update a policy group. Only provided fields are updated; omitted fields remain unchanged. A deleted group cannot be updated (409 P-059).",
3617
3642
  inputSchema: z2.object({
3618
3643
  groupId: z2.string().uuid().describe("Policy group ID"),
3619
3644
  name: z2.string().optional().describe("New name"),
3620
3645
  description: z2.string().optional().describe("New description"),
3621
- status: z2.enum(["ACTIVE", "DISABLED"]).optional().describe("Status (DISABLED = emergency stop)"),
3646
+ status: z2.enum(["ACTIVE", "DISABLED"]).optional().describe(
3647
+ "DISABLED stops production execution of this group: execution calls return 403 (P-009), and a composite call that includes the group is rejected in full. The deployed version and any A/B test are kept, and dry run, Impact Simulation, and Decision Replay keep working, so a fix can be verified before setting ACTIVE again. To go back to the previous version, use lexq_deploy_rollback. To take the version out of production, use lexq_deploy_undeploy."
3648
+ ),
3622
3649
  activationGroup: z2.string().optional().describe(
3623
3650
  "Activation group (Execution Group) cluster key. Policy groups sharing this key compete, and group priority picks the winners."
3624
3651
  ),
@@ -3636,7 +3663,7 @@ function registerGroupTools(server, callApi) {
3636
3663
  {
3637
3664
  title: "Delete Policy Group",
3638
3665
  annotations: { readOnlyHint: false, destructiveHint: true },
3639
- description: "Archive a policy group. Only non-live groups can be deleted. This is irreversible.",
3666
+ description: "Delete (archive) a policy group. A deployed group cannot be deleted (P-020); undeploy it first with lexq_deploy_undeploy, which also ends a running A/B test. This cannot be undone. The group no longer appears in lexq_groups_list and its name can be reused right away. Calls that change it (settings, versions, rules, starting an A/B test, publish, deploy, rollback, scheduling a deployment) return 409 P-059. If it was ever executed, deployed, or replayed, it is kept for audit; otherwise it is permanently deleted after 30 days.",
3640
3667
  inputSchema: z2.object({
3641
3668
  groupId: z2.string().uuid().describe("Policy group ID")
3642
3669
  })
@@ -3842,7 +3869,12 @@ function registerRuleTools(server, callApi) {
3842
3869
  If a required key is missing, ask the user to confirm the type, isRequired, and description
3843
3870
  before calling lexq_facts_create — registering facts enables type validation, Console UI
3844
3871
  autocomplete, and the dry-run requirements analyzer.
3845
-
3872
+
3873
+ An action's targetVar (SET_FACT, MUTATE_FACT) must be a registered fact. Saving or
3874
+ publishing a rule whose targetVar is not registered fails with ACT-032, so register it
3875
+ with lexq_facts_create first. Keys a rule only reads (condition fields, refVar) may stay
3876
+ unregistered.
3877
+
3846
3878
  After saving, lexq_facts_unregistered lists any keys this version references but has not
3847
3879
  defined (non-blocking, version-wide) — use it to decide what to register.
3848
3880
 
@@ -3871,8 +3903,9 @@ function registerRuleTools(server, callApi) {
3871
3903
 
3872
3904
  Action parameter schemas:
3873
3905
  - MUTATE_FACT: { targetVar: string, operator: "ASSIGN"|"ADD"|"SUB"|"MUL"|"DIV", method: "PERCENTAGE"|"AMOUNT", operand: number, refVar?: string, rounding?: RoundingOption }
3874
- targetVar is the fact this action reads and writes. It must exist in facts at execution
3875
- time as a number — supplied as an input fact or written by a prior action in this rule.
3906
+ targetVar is the fact this action reads and writes. It must be a registered fact
3907
+ (ACT-032 otherwise), and the request must send it (P-015 otherwise). Its value must be
3908
+ a number when the action runs.
3876
3909
  A missing required fact throws (no 0 default).
3877
3910
  operand is the arithmetic operand; the unit is dictated by method (percent when
3878
3911
  PERCENTAGE, absolute amount when AMOUNT). Ranges are not constrained — negative values
@@ -3890,8 +3923,10 @@ function registerRuleTools(server, callApi) {
3890
3923
  DIV targetVar /= operand | invalid
3891
3924
  Constraints: DIV + PERCENTAGE is invalid (use MUL with the inverse). DIV + AMOUNT
3892
3925
  requires operand !== 0.
3893
- - SET_FACT: { targetVar: string, value: string|number|boolean } Creates the fact if absent
3894
- — this is the only action that does. MUTATE_FACT requires the target to already exist.
3926
+ - SET_FACT: { targetVar: string, value: string|number|boolean } targetVar must be a
3927
+ registered fact (ACT-032 otherwise). The request does not have to send it: SET_FACT is
3928
+ the only action that can write a key absent from the input facts. MUTATE_FACT requires
3929
+ its target to be present in the input facts.
3895
3930
  - BLOCK: { reason: string } Records a rejection decision. It does NOT halt rule execution —
3896
3931
  subsequent actions and subsequent winning rules still run. Enforcement is the caller's
3897
3932
  responsibility; the decision surfaces as the isBlocked fact.
@@ -3916,7 +3951,7 @@ function registerRuleTools(server, callApi) {
3916
3951
  {
3917
3952
  title: "Update Rule",
3918
3953
  annotations: { readOnlyHint: false, destructiveHint: true },
3919
- 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.`,
3954
+ 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. Saving re-checks every action in the rule, including ones you did not change, and fails with ACT-032 if any action's targetVar is not a registered fact.`,
3920
3955
  inputSchema: z4.object({
3921
3956
  groupId: z4.string().uuid().describe("Policy group ID"),
3922
3957
  versionId: z4.string().uuid().describe("Version ID"),
@@ -4055,13 +4090,13 @@ function registerFactTools(server, callApi) {
4055
4090
  {
4056
4091
  title: "Update Fact Definition",
4057
4092
  annotations: { readOnlyHint: false, destructiveHint: true },
4058
- description: "Update a fact definition. The key is immutable. The type can change only while no rule references the fact; if any does, the call fails with FD-007 and reports the count. Only the fields you send are changed. System facts accept name, description, and PII only.",
4093
+ description: "Update a fact definition. The key is immutable. The type can change only while no rule references the fact; if any does, the call fails with FD-007 and reports the count. Rules in deleted groups do not count. Only the fields you send are changed. System facts accept name, description, and PII only.",
4059
4094
  inputSchema: z5.object({
4060
4095
  factId: z5.string().uuid().describe("Fact definition ID"),
4061
4096
  name: z5.string().optional().describe("Display name"),
4062
4097
  description: z5.string().optional().describe("Description"),
4063
4098
  type: z5.enum(VALUE_TYPES).optional().describe(
4064
- "Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule references the fact."
4099
+ "Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule outside a deleted group references the fact."
4065
4100
  ),
4066
4101
  isRequired: z5.boolean().optional().describe("Required flag"),
4067
4102
  isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
@@ -4083,7 +4118,7 @@ function registerFactTools(server, callApi) {
4083
4118
  {
4084
4119
  title: "Delete Fact Definition",
4085
4120
  annotations: { readOnlyHint: false, destructiveHint: true },
4086
- description: "Delete a fact definition. System facts cannot be deleted. Neither can a fact that any rule references: that call fails with FD-006 and reports the count. Remove the references first.",
4121
+ description: "Delete a fact definition. System facts cannot be deleted. Neither can a fact that any rule references: that call fails with FD-006 and reports the count. Rules in deleted groups do not count. Remove the references first.",
4087
4122
  inputSchema: z5.object({
4088
4123
  factId: z5.string().uuid().describe("Fact definition ID")
4089
4124
  })
@@ -4095,7 +4130,7 @@ function registerFactTools(server, callApi) {
4095
4130
  {
4096
4131
  title: "Get Action Runtime Fact Metadata",
4097
4132
  annotations: { readOnlyHint: true },
4098
- description: "Retrieve runtime fact requirements per Action type. For each Action, shows which input facts must be present in the execution payload \u2014 e.g. MUTATE_FACT always requires its targetVar fact, plus refVar when one is specified. The factRequired flag describes the FACT, not the parameter: refVar is an optional parameter, but if you specify it the named fact must exist. A required fact absent at runtime throws \u2014 the engine never defaults to 0. Facts are supplied as input or written by a prior action in the same rule; only SET_FACT creates a fact from nothing. Static data, safe to cache in-session.",
4133
+ description: "Retrieve runtime fact requirements per Action type. For each Action, shows which input facts must be present in the execution payload \u2014 e.g. MUTATE_FACT always requires its targetVar fact, plus refVar when one is specified. The factRequired flag describes the FACT, not the parameter: refVar is an optional parameter, but if you specify it the named fact must exist. A required fact absent at runtime throws \u2014 the engine never defaults to 0. These facts must be sent in the request (P-015 otherwise), even when an earlier action in the same rule writes them. SET_FACT requires no input fact: it is the only action that can write a key the request did not send, and its targetVar must be a registered fact (ACT-032). Static data, safe to cache in-session.",
4099
4134
  inputSchema: z5.object({})
4100
4135
  },
4101
4136
  async () => callApi("GET", "schema/action-metadata")
@@ -4105,7 +4140,7 @@ function registerFactTools(server, callApi) {
4105
4140
  {
4106
4141
  title: "List Unregistered Facts",
4107
4142
  annotations: { readOnlyHint: true },
4108
- description: "List facts referenced by a version's rules but not yet defined (read-only \u2014 does not block publish/deploy). Version-wide: covers every rule in the version. Each entry carries the inferred type, suggested name, and where it is referenced (condition/action). Register them with lexq_facts_create to enable type validation and the dry-run requirements analyzer.",
4143
+ description: "List facts referenced by a version's rules but not yet defined (read-only). Keys that rules only read do not block publish/deploy; a rule whose action targetVar is not a registered fact is rejected at save and publish with ACT-032. Version-wide: covers every rule in the version. Each entry carries the inferred type, suggested name, and where it is referenced (condition/action). Register them with lexq_facts_create to enable type validation and the dry-run requirements analyzer.",
4109
4144
  inputSchema: z5.object({
4110
4145
  groupId: z5.string().uuid().describe("Policy group ID"),
4111
4146
  versionId: z5.string().uuid().describe("Version ID")
@@ -4138,7 +4173,7 @@ function registerDeployTools(server, callApi) {
4138
4173
  {
4139
4174
  title: "Publish Version",
4140
4175
  annotations: { readOnlyHint: false, destructiveHint: false },
4141
- 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; call lexq_facts_unregistered first to review them. Publishing fails if the version's compiled rules exceed ${MAX_SNAPSHOT_BYTES / 1024} KB, or if its effective end has already passed (P-058); move the end into the future with lexq_versions_update first.`,
4176
+ description: `Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule. Undefined facts that rules only read do not block publishing; call lexq_facts_unregistered first to review them. Publishing fails if an action's targetVar is not a registered fact (ACT-032), if the version's compiled rules exceed ${MAX_SNAPSHOT_BYTES / 1024} KB, or if its effective end has already passed (P-058); move the end into the future with lexq_versions_update first.`,
4142
4177
  inputSchema: z6.object({
4143
4178
  groupId: z6.string().uuid().describe("Policy group ID"),
4144
4179
  versionId: z6.string().uuid().describe("Version ID to publish"),
@@ -4395,7 +4430,8 @@ function registerAnalyticsTools(server, callApi) {
4395
4430
  UPLOADED → source S3_BUCKET, with dataset.path (the path returned by lexq_dataset_upload)
4396
4431
  MANUAL → source REQUEST_BODY, with dataset.manualData (array of fact records)
4397
4432
 
4398
- options.maxRecords: number (max 100000, default 10000)
4433
+ options.maxRecords: number (max ${MAX_SIMULATION_RECORDS}). When omitted, HISTORICAL and UPLOADED run at
4434
+ most ${DEFAULT_SIMULATION_RECORDS} records and MANUAL runs every record sent.
4399
4435
  options.baselinePolicyVersionId: uuid (optional, for baseline comparison)
4400
4436
  options.includeRuleStats: boolean
4401
4437
  options.metricConfig: optional — omit for plain execution count. To aggregate a fact, pass
@@ -4875,7 +4911,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
4875
4911
  {
4876
4912
  title: "List Webhook Subscriptions",
4877
4913
  annotations: { readOnlyHint: true },
4878
- description: "List platform event webhook subscriptions. These receive deployment lifecycle notifications (publish, deploy, rollback, undeploy).",
4914
+ description: "List platform event webhook subscriptions. These receive deployment lifecycle notifications: publish, deploy, rollback, and undeploy, plus a deployment being scheduled or a schedule being canceled.",
4879
4915
  inputSchema: z13.object({
4880
4916
  page: z13.number().int().min(0).default(0).describe("Page number"),
4881
4917
  size: z13.number().int().min(1).max(100).default(20).describe("Page size")
@@ -4903,7 +4939,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
4903
4939
  {
4904
4940
  title: "Save Webhook Subscription",
4905
4941
  annotations: { readOnlyHint: false, destructiveHint: true },
4906
- description: 'Create or update a webhook subscription. Omit id to create, provide id to update. Events: VERSION_PUBLISHED, DEPLOYED, ROLLED_BACK, UNDEPLOYED. Formats: GENERIC (full JSON), SLACK ({"text": "..."}).',
4942
+ description: `Create or update a webhook subscription. Omit id to create, provide id to update. Events: ${PlatformEventType.join(", ")}. Formats: GENERIC (full JSON), SLACK ({"text": "..."}).`,
4907
4943
  inputSchema: z13.object({
4908
4944
  id: z13.string().uuid().optional().describe("Subscription ID (omit to create, provide to update)"),
4909
4945
  name: z13.string().min(1).describe("Subscription name (unique per tenant)"),
@@ -37,6 +37,8 @@ var WebhookPayloadFormat = ["GENERIC", "SLACK"];
37
37
  // src/types/constants.ts
38
38
  var MAX_ROUNDING_SCALE = 34;
39
39
  var REPLAY_WINDOW_MAX_RECORDS = 5e4;
40
+ var DEFAULT_SIMULATION_RECORDS = 1e3;
41
+ var MAX_SIMULATION_RECORDS = 1e5;
40
42
  var PROFILE_SAMPLE_PERMILLE = 10;
41
43
  var LATENCY_WINDOW_MILLIS = 6e4;
42
44
  var TAIL_MIN_OBS = 3;
@@ -58,7 +60,7 @@ function registerGroupTools(server, callApi) {
58
60
  {
59
61
  title: "List Policy Groups",
60
62
  annotations: { readOnlyHint: true },
61
- description: "List all policy groups (tenant-wide, priority ASC).",
63
+ description: "List policy groups (tenant-wide, priority ASC). Deleted groups are not included.",
62
64
  inputSchema: z2.object({})
63
65
  },
64
66
  async () => callApi("GET", "policy-groups")
@@ -111,12 +113,14 @@ function registerGroupTools(server, callApi) {
111
113
  {
112
114
  title: "Update Policy Group",
113
115
  annotations: { readOnlyHint: false, destructiveHint: true },
114
- description: "Update a policy group. Only provided fields are updated; omitted fields remain unchanged.",
116
+ description: "Update a policy group. Only provided fields are updated; omitted fields remain unchanged. A deleted group cannot be updated (409 P-059).",
115
117
  inputSchema: z2.object({
116
118
  groupId: z2.string().uuid().describe("Policy group ID"),
117
119
  name: z2.string().optional().describe("New name"),
118
120
  description: z2.string().optional().describe("New description"),
119
- status: z2.enum(["ACTIVE", "DISABLED"]).optional().describe("Status (DISABLED = emergency stop)"),
121
+ status: z2.enum(["ACTIVE", "DISABLED"]).optional().describe(
122
+ "DISABLED stops production execution of this group: execution calls return 403 (P-009), and a composite call that includes the group is rejected in full. The deployed version and any A/B test are kept, and dry run, Impact Simulation, and Decision Replay keep working, so a fix can be verified before setting ACTIVE again. To go back to the previous version, use lexq_deploy_rollback. To take the version out of production, use lexq_deploy_undeploy."
123
+ ),
120
124
  activationGroup: z2.string().optional().describe(
121
125
  "Activation group (Execution Group) cluster key. Policy groups sharing this key compete, and group priority picks the winners."
122
126
  ),
@@ -134,7 +138,7 @@ function registerGroupTools(server, callApi) {
134
138
  {
135
139
  title: "Delete Policy Group",
136
140
  annotations: { readOnlyHint: false, destructiveHint: true },
137
- description: "Archive a policy group. Only non-live groups can be deleted. This is irreversible.",
141
+ description: "Delete (archive) a policy group. A deployed group cannot be deleted (P-020); undeploy it first with lexq_deploy_undeploy, which also ends a running A/B test. This cannot be undone. The group no longer appears in lexq_groups_list and its name can be reused right away. Calls that change it (settings, versions, rules, starting an A/B test, publish, deploy, rollback, scheduling a deployment) return 409 P-059. If it was ever executed, deployed, or replayed, it is kept for audit; otherwise it is permanently deleted after 30 days.",
138
142
  inputSchema: z2.object({
139
143
  groupId: z2.string().uuid().describe("Policy group ID")
140
144
  })
@@ -409,7 +413,12 @@ function registerRuleTools(server, callApi) {
409
413
  If a required key is missing, ask the user to confirm the type, isRequired, and description
410
414
  before calling lexq_facts_create — registering facts enables type validation, Console UI
411
415
  autocomplete, and the dry-run requirements analyzer.
412
-
416
+
417
+ An action's targetVar (SET_FACT, MUTATE_FACT) must be a registered fact. Saving or
418
+ publishing a rule whose targetVar is not registered fails with ACT-032, so register it
419
+ with lexq_facts_create first. Keys a rule only reads (condition fields, refVar) may stay
420
+ unregistered.
421
+
413
422
  After saving, lexq_facts_unregistered lists any keys this version references but has not
414
423
  defined (non-blocking, version-wide) — use it to decide what to register.
415
424
 
@@ -438,8 +447,9 @@ function registerRuleTools(server, callApi) {
438
447
 
439
448
  Action parameter schemas:
440
449
  - MUTATE_FACT: { targetVar: string, operator: "ASSIGN"|"ADD"|"SUB"|"MUL"|"DIV", method: "PERCENTAGE"|"AMOUNT", operand: number, refVar?: string, rounding?: RoundingOption }
441
- targetVar is the fact this action reads and writes. It must exist in facts at execution
442
- time as a number — supplied as an input fact or written by a prior action in this rule.
450
+ targetVar is the fact this action reads and writes. It must be a registered fact
451
+ (ACT-032 otherwise), and the request must send it (P-015 otherwise). Its value must be
452
+ a number when the action runs.
443
453
  A missing required fact throws (no 0 default).
444
454
  operand is the arithmetic operand; the unit is dictated by method (percent when
445
455
  PERCENTAGE, absolute amount when AMOUNT). Ranges are not constrained — negative values
@@ -457,8 +467,10 @@ function registerRuleTools(server, callApi) {
457
467
  DIV targetVar /= operand | invalid
458
468
  Constraints: DIV + PERCENTAGE is invalid (use MUL with the inverse). DIV + AMOUNT
459
469
  requires operand !== 0.
460
- - SET_FACT: { targetVar: string, value: string|number|boolean } Creates the fact if absent
461
- — this is the only action that does. MUTATE_FACT requires the target to already exist.
470
+ - SET_FACT: { targetVar: string, value: string|number|boolean } targetVar must be a
471
+ registered fact (ACT-032 otherwise). The request does not have to send it: SET_FACT is
472
+ the only action that can write a key absent from the input facts. MUTATE_FACT requires
473
+ its target to be present in the input facts.
462
474
  - BLOCK: { reason: string } Records a rejection decision. It does NOT halt rule execution —
463
475
  subsequent actions and subsequent winning rules still run. Enforcement is the caller's
464
476
  responsibility; the decision surfaces as the isBlocked fact.
@@ -483,7 +495,7 @@ function registerRuleTools(server, callApi) {
483
495
  {
484
496
  title: "Update Rule",
485
497
  annotations: { readOnlyHint: false, destructiveHint: true },
486
- 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.`,
498
+ 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. Saving re-checks every action in the rule, including ones you did not change, and fails with ACT-032 if any action's targetVar is not a registered fact.`,
487
499
  inputSchema: z4.object({
488
500
  groupId: z4.string().uuid().describe("Policy group ID"),
489
501
  versionId: z4.string().uuid().describe("Version ID"),
@@ -626,13 +638,13 @@ function registerFactTools(server, callApi) {
626
638
  {
627
639
  title: "Update Fact Definition",
628
640
  annotations: { readOnlyHint: false, destructiveHint: true },
629
- description: "Update a fact definition. The key is immutable. The type can change only while no rule references the fact; if any does, the call fails with FD-007 and reports the count. Only the fields you send are changed. System facts accept name, description, and PII only.",
641
+ description: "Update a fact definition. The key is immutable. The type can change only while no rule references the fact; if any does, the call fails with FD-007 and reports the count. Rules in deleted groups do not count. Only the fields you send are changed. System facts accept name, description, and PII only.",
630
642
  inputSchema: z5.object({
631
643
  factId: z5.string().uuid().describe("Fact definition ID"),
632
644
  name: z5.string().optional().describe("Display name"),
633
645
  description: z5.string().optional().describe("Description"),
634
646
  type: z5.enum(VALUE_TYPES).optional().describe(
635
- "Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule references the fact."
647
+ "Value type. Omit to leave it unchanged. Changing it fails with FD-007 while any rule outside a deleted group references the fact."
636
648
  ),
637
649
  isRequired: z5.boolean().optional().describe("Required flag"),
638
650
  isPii: z5.boolean().optional().describe("PII flag \u2014 enables/disables masking (changeable even on system facts)"),
@@ -654,7 +666,7 @@ function registerFactTools(server, callApi) {
654
666
  {
655
667
  title: "Delete Fact Definition",
656
668
  annotations: { readOnlyHint: false, destructiveHint: true },
657
- description: "Delete a fact definition. System facts cannot be deleted. Neither can a fact that any rule references: that call fails with FD-006 and reports the count. Remove the references first.",
669
+ description: "Delete a fact definition. System facts cannot be deleted. Neither can a fact that any rule references: that call fails with FD-006 and reports the count. Rules in deleted groups do not count. Remove the references first.",
658
670
  inputSchema: z5.object({
659
671
  factId: z5.string().uuid().describe("Fact definition ID")
660
672
  })
@@ -666,7 +678,7 @@ function registerFactTools(server, callApi) {
666
678
  {
667
679
  title: "Get Action Runtime Fact Metadata",
668
680
  annotations: { readOnlyHint: true },
669
- description: "Retrieve runtime fact requirements per Action type. For each Action, shows which input facts must be present in the execution payload \u2014 e.g. MUTATE_FACT always requires its targetVar fact, plus refVar when one is specified. The factRequired flag describes the FACT, not the parameter: refVar is an optional parameter, but if you specify it the named fact must exist. A required fact absent at runtime throws \u2014 the engine never defaults to 0. Facts are supplied as input or written by a prior action in the same rule; only SET_FACT creates a fact from nothing. Static data, safe to cache in-session.",
681
+ description: "Retrieve runtime fact requirements per Action type. For each Action, shows which input facts must be present in the execution payload \u2014 e.g. MUTATE_FACT always requires its targetVar fact, plus refVar when one is specified. The factRequired flag describes the FACT, not the parameter: refVar is an optional parameter, but if you specify it the named fact must exist. A required fact absent at runtime throws \u2014 the engine never defaults to 0. These facts must be sent in the request (P-015 otherwise), even when an earlier action in the same rule writes them. SET_FACT requires no input fact: it is the only action that can write a key the request did not send, and its targetVar must be a registered fact (ACT-032). Static data, safe to cache in-session.",
670
682
  inputSchema: z5.object({})
671
683
  },
672
684
  async () => callApi("GET", "schema/action-metadata")
@@ -676,7 +688,7 @@ function registerFactTools(server, callApi) {
676
688
  {
677
689
  title: "List Unregistered Facts",
678
690
  annotations: { readOnlyHint: true },
679
- description: "List facts referenced by a version's rules but not yet defined (read-only \u2014 does not block publish/deploy). Version-wide: covers every rule in the version. Each entry carries the inferred type, suggested name, and where it is referenced (condition/action). Register them with lexq_facts_create to enable type validation and the dry-run requirements analyzer.",
691
+ description: "List facts referenced by a version's rules but not yet defined (read-only). Keys that rules only read do not block publish/deploy; a rule whose action targetVar is not a registered fact is rejected at save and publish with ACT-032. Version-wide: covers every rule in the version. Each entry carries the inferred type, suggested name, and where it is referenced (condition/action). Register them with lexq_facts_create to enable type validation and the dry-run requirements analyzer.",
680
692
  inputSchema: z5.object({
681
693
  groupId: z5.string().uuid().describe("Policy group ID"),
682
694
  versionId: z5.string().uuid().describe("Version ID")
@@ -709,7 +721,7 @@ function registerDeployTools(server, callApi) {
709
721
  {
710
722
  title: "Publish Version",
711
723
  annotations: { readOnlyHint: false, destructiveHint: false },
712
- 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; call lexq_facts_unregistered first to review them. Publishing fails if the version's compiled rules exceed ${MAX_SNAPSHOT_BYTES / 1024} KB, or if its effective end has already passed (P-058); move the end into the future with lexq_versions_update first.`,
724
+ description: `Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule. Undefined facts that rules only read do not block publishing; call lexq_facts_unregistered first to review them. Publishing fails if an action's targetVar is not a registered fact (ACT-032), if the version's compiled rules exceed ${MAX_SNAPSHOT_BYTES / 1024} KB, or if its effective end has already passed (P-058); move the end into the future with lexq_versions_update first.`,
713
725
  inputSchema: z6.object({
714
726
  groupId: z6.string().uuid().describe("Policy group ID"),
715
727
  versionId: z6.string().uuid().describe("Version ID to publish"),
@@ -966,7 +978,8 @@ function registerAnalyticsTools(server, callApi) {
966
978
  UPLOADED → source S3_BUCKET, with dataset.path (the path returned by lexq_dataset_upload)
967
979
  MANUAL → source REQUEST_BODY, with dataset.manualData (array of fact records)
968
980
 
969
- options.maxRecords: number (max 100000, default 10000)
981
+ options.maxRecords: number (max ${MAX_SIMULATION_RECORDS}). When omitted, HISTORICAL and UPLOADED run at
982
+ most ${DEFAULT_SIMULATION_RECORDS} records and MANUAL runs every record sent.
970
983
  options.baselinePolicyVersionId: uuid (optional, for baseline comparison)
971
984
  options.includeRuleStats: boolean
972
985
  options.metricConfig: optional — omit for plain execution count. To aggregate a fact, pass
@@ -1446,7 +1459,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
1446
1459
  {
1447
1460
  title: "List Webhook Subscriptions",
1448
1461
  annotations: { readOnlyHint: true },
1449
- description: "List platform event webhook subscriptions. These receive deployment lifecycle notifications (publish, deploy, rollback, undeploy).",
1462
+ description: "List platform event webhook subscriptions. These receive deployment lifecycle notifications: publish, deploy, rollback, and undeploy, plus a deployment being scheduled or a schedule being canceled.",
1450
1463
  inputSchema: z13.object({
1451
1464
  page: z13.number().int().min(0).default(0).describe("Page number"),
1452
1465
  size: z13.number().int().min(1).max(100).default(20).describe("Page size")
@@ -1474,7 +1487,7 @@ function registerWebhookSubscriptionTools(server, callApi) {
1474
1487
  {
1475
1488
  title: "Save Webhook Subscription",
1476
1489
  annotations: { readOnlyHint: false, destructiveHint: true },
1477
- description: 'Create or update a webhook subscription. Omit id to create, provide id to update. Events: VERSION_PUBLISHED, DEPLOYED, ROLLED_BACK, UNDEPLOYED. Formats: GENERIC (full JSON), SLACK ({"text": "..."}).',
1490
+ description: `Create or update a webhook subscription. Omit id to create, provide id to update. Events: ${PlatformEventType.join(", ")}. Formats: GENERIC (full JSON), SLACK ({"text": "..."}).`,
1478
1491
  inputSchema: z13.object({
1479
1492
  id: z13.string().uuid().optional().describe("Subscription ID (omit to create, provide to update)"),
1480
1493
  name: z13.string().min(1).describe("Subscription name (unique per tenant)"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexq/cli",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
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",
@@ -17,9 +17,24 @@ controls conflict resolution when multiple groups interact.
17
17
  | `DISABLED` | `ARCHIVED` | **no** |
18
18
 
19
19
  - **ACTIVE** — executions are processed.
20
- - **DISABLED** — all executions are blocked. Use for emergency stop.
20
+ - **DISABLED** — production execution stops. Execution calls return `P-009` (403), and a composite call that includes
21
+ the group is rejected in full. The live version and any A/B test are kept, and dry run, Impact Simulation, and
22
+ Decision Replay keep working, so you can verify a fix before setting `ACTIVE` again.
21
23
  - **ARCHIVED** — removed from execution. Reached only through `lexq groups delete`, not by setting `status` directly.
22
24
 
25
+ ### Disable, Roll Back, or Undeploy?
26
+
27
+ | Situation | Use | Callers get |
28
+ |-----------------------------------------------------------------------|------------------------|------------------------------------------------------------------------|
29
+ | The previously deployed version is correct | `lexq deploy rollback` | Decisions from the previous version |
30
+ | This version has to leave production | `lexq deploy undeploy` | `P-019` (400). Composite calls skip the group. A running A/B test ends |
31
+ | Stop now without knowing the cause, then resume with the same version | `status: DISABLED` | `P-009` (403). Composite calls that include the group are rejected |
32
+
33
+ Do not disable a group for a planned end, such as a promotion with a known end date. Set the version's `effectiveTo`
34
+ instead. When that time passes, the group switches to its newest `ACTIVE` version that is still in effect, or is
35
+ undeployed if there is none. Composite calls keep working either way. A disabled group rejects every composite call
36
+ that includes it.
37
+
23
38
  ## CRUD Commands
24
39
 
25
40
  ### List Groups
@@ -93,20 +108,31 @@ lexq groups delete --id <groupId>
93
108
  lexq groups delete --id <groupId> --force
94
109
  ```
95
110
 
96
- **`delete` archives — it does not erase.** The group becomes `ARCHIVED` and stops executing;
97
- versions, rules, and history are retained for audit. Deleting an already-archived group is a
98
- no-op.
111
+ **`delete` archives — it does not erase right away.** The group becomes `ARCHIVED` and stops
112
+ executing. If it was ever executed, deployed, or replayed, it is kept for audit, along with its
113
+ versions and rules. Otherwise it is permanently deleted after 30 days, together with its
114
+ versions, rules, simulations, and schedules. Deleting an already-archived group is a no-op.
99
115
 
100
- Two conditions block it:
101
-
102
- | Condition | What to do first |
103
- |------------------------|-----------------------------------------------------|
104
- | The group is deployed | `lexq deploy undeploy --group-id <gid>` |
105
- | An A/B test is running | `lexq groups ab-test stop --group-id <gid> --force` |
116
+ A deployed group cannot be deleted (`P-020`). Undeploy it first with
117
+ `lexq deploy undeploy --group-id <gid> --memo "<reason>"`, which also ends a running A/B test.
106
118
 
107
119
  Archiving cancels any pending scheduled deployment and closes the priority gap — remaining groups
108
120
  are renumbered 1…N.
109
121
 
122
+ A deleted group no longer appears in `lexq groups list` and cannot be restored or changed.
123
+ Changing its settings, versions, or rules, starting an A/B test, and publishing, deploying,
124
+ rolling back, or scheduling a deployment all return `409` `P-059`. To start over, create a new
125
+ group.
126
+
127
+ **The name can be reused right away.** Records keep the group's ID and the name it had, so after
128
+ a new group takes the name, deployment history, scheduled deployments, execution history,
129
+ simulations, and replays can list two groups under that name. These records carry
130
+ `policyGroupArchivedAt`, the time the group was deleted (`null` if it has not been). In the
131
+ tables of `lexq deploy history`, `deploy schedules`, `history list`, and
132
+ `analytics simulation list` (`--format table`), a deleted group's name shows as
133
+ `(deleted) <name>`. To see one group's deployments or executions only, pass its ID to
134
+ `lexq deploy history --group-id` or `lexq history list --group-id`.
135
+
110
136
  ## Conflict Resolution
111
137
 
112
138
  ### Activation Mode (across groups in the same `activationGroup`)
@@ -246,4 +272,5 @@ lets you reuse the existing group instead of handling the error.
246
272
  lexq groups update --id <groupId> --json '{"status": "DISABLED"}'
247
273
  ```
248
274
 
249
- This immediately stops all executions for the group. No undeploy needed.
275
+ This immediately stops production execution for the group. Dry run, Impact Simulation, and Decision Replay keep
276
+ working. No undeploy needed.
@@ -480,8 +480,10 @@ lexq analytics dry-run --version-id <vid> --debug --json '{
480
480
 
481
481
  **Notes**
482
482
 
483
- - `SET_FACT` creates the fact if it does not exist. `MUTATE_FACT` requires the target to already
484
- be present — that is the division of labor between them.
483
+ - Register a `SET_FACT` target before saving the rule, as the step above does. A rule whose
484
+ `targetVar` is not a registered fact is rejected with `ACT-032`.
485
+ - At execution, `SET_FACT` writes the key even when the request did not send it. `MUTATE_FACT`
486
+ requires the request to send its target. That is the division of labor between them.
485
487
  - Flags appear in `generatedVariables` in the response. `SET_FACT` produces no `__delta` because
486
488
  it is an assignment, not an arithmetic change.
487
489
  - Segment on a `LIST_STRING` fact with `HAS_ANY` / `HAS_ALL` / `HAS_NONE`, not `CONTAINS`.
@@ -181,14 +181,14 @@ Each rule can have multiple actions. Actions fire sequentially.
181
181
  | Type | Description | Key Parameters |
182
182
  |---------------|--------------------------------------------|----------------------------------------------------------------------|
183
183
  | `MUTATE_FACT` | Arithmetic change to a numeric fact | `targetVar`, `operator`, `method`, `operand`, `refVar?`, `rounding?` |
184
- | `SET_FACT` | Assign a literal value — creates if absent | `targetVar`, `value` |
184
+ | `SET_FACT` | Assign a literal value to a registered fact | `targetVar`, `value` |
185
185
  | `BLOCK` | Record a rejection decision | `reason` |
186
186
 
187
187
  ### `MUTATE_FACT` parameters
188
188
 
189
189
  | Parameter | Required | Meaning |
190
190
  |-------------|----------|-------------------------------------------------------------------------------|
191
- | `targetVar` | always | The fact this action **reads and writes**. Must already exist as a number. |
191
+ | `targetVar` | always | The fact this action **reads and writes**. Must be registered and present as a number at execution. |
192
192
  | `operator` | always | `ASSIGN` \| `ADD` \| `SUB` \| `MUL` \| `DIV` |
193
193
  | `method` | always | `PERCENTAGE` \| `AMOUNT` — dictates the unit of `operand` |
194
194
  | `operand` | always | The arithmetic operand. Percent when PERCENTAGE, absolute amount when AMOUNT. |
@@ -235,8 +235,15 @@ Use `refVar` when the base differs from the target:
235
235
 
236
236
  ### `SET_FACT` vs `MUTATE_FACT`
237
237
 
238
- `SET_FACT` creates the fact if it does not exist. `MUTATE_FACT` requires the target to already be
239
- present as a number and throws otherwise. "Make something that wasn't there" is `SET_FACT`'s job.
238
+ Both actions write to `targetVar`, and it must be a **registered fact**. Saving or publishing a
239
+ rule whose `targetVar` is not registered fails with `ACT-032`; register it with
240
+ `lexq facts create` first. Keys a rule only reads (condition fields, `refVar`) may stay
241
+ unregistered.
242
+
243
+ At execution, they differ in what the request must send. `SET_FACT` writes the value even when
244
+ the request did not send the key. `MUTATE_FACT` requires the request to send the target (`P-015`
245
+ otherwise), and the target's value must be a number when the action runs. "Write something the
246
+ request didn't send" is `SET_FACT`'s job.
240
247
 
241
248
  ### `BLOCK` does not halt execution
242
249
 
@@ -19,6 +19,9 @@ lexq auth login
19
19
  lexq auth whoami
20
20
  ```
21
21
 
22
+ Create the key with the **Manage** scope in the Console (Management → API Keys). An **Execute only** key is
23
+ for calling the execution API from your application and does not work with this CLI.
24
+
22
25
  The API key is passed as `X-API-KEY` header on every request. You can also override per-command:
23
26
 
24
27
  ```bash