@lexq/cli 1.0.2 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -180,8 +180,12 @@ The MCP toolset mirrors the CLI command inventory one-to-one.
180
180
  - Releasing is two steps, in this order. Raise `version` in package.json inside the pull request, so a reviewer
181
181
  sees the number beside the change that justifies it. After it merges, push the matching `v<version>` tag — that
182
182
  tag is what publishes. A tag that disagrees with package.json is rejected before anything reaches npm.
183
- - Pick the number by what breaks. On 0.x a breaking change is a minor bump (0.1 → 0.2), not a patch. Raising the
184
- Node floor, a peer dependency major, or any change to what `tools/list` returns is breaking.
183
+ - Pick the number by what breaks. A change is breaking when a call that used to work now fails or does something
184
+ else. That includes removing or renaming a tool, command, option or parameter, changing a parameter's type,
185
+ adding a required parameter or making an optional one required, and narrowing the values a parameter accepts.
186
+ Raising the Node floor or moving a peer dependency to a new major version is breaking too. A breaking change
187
+ raises the major number. A new tool, command, option or optional parameter raises the minor, and anything else,
188
+ including a changed description, raises the patch.
185
189
  - Gates before commit: `pnpm typecheck` (zero errors), `pnpm lint` (zero warnings), `pnpm test:fact-key`,
186
190
  `pnpm test:decimals`.
187
191
  - Multi-line CLI help and MCP tool descriptions use `dedent` — template-literal indentation leaks into LLM context.
package/dist/index.js CHANGED
@@ -804,18 +804,18 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
804
804
  process.exit(1);
805
805
  }
806
806
  });
807
- versions.command("update").description("Update a draft version metadata").requiredOption("--group-id <groupId>", "Policy group ID").requiredOption("--id <versionId>", "Policy version ID").option("--commit-message <message>", "Commit message").option("--effective-from <instant>", `Effective start, ${INSTANT}`).option("--effective-to <instant>", `Effective end, ${INSTANT}`).option("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
807
+ versions.command("update").description("Update a draft version metadata").requiredOption("--group-id <groupId>", "Policy group ID").requiredOption("--id <versionId>", "Policy version ID").option("--commit-message <message>", "Commit message").option("--effective-from <instant>", `Effective start, ${INSTANT}`).option("--effective-to <instant>", `Effective end, ${INSTANT}`).option("--clear-effective-from", "Remove the effective start").option("--clear-effective-to", "Remove the effective end").option("--json <body>", "Full request body as JSON (overrides other options)").addHelpText(
808
808
  "after",
809
809
  dedent4`
810
810
 
811
811
  Only DRAFT versions can be updated. Published (ACTIVE) versions are immutable.
812
812
 
813
- An omitted --commit-message keeps its value, but --effective-from and --effective-to
814
- are replaced together: one you omit is cleared. To keep the effective window, pass
815
- both current values (read them with lexq versions get).
813
+ Omitted options keep their current values. To remove an end of the effective
814
+ window, pass --clear-effective-from or --clear-effective-to instead of a value.
816
815
 
817
- Example:
816
+ Examples:
818
817
  $ lexq versions update --group-id <gid> --id <vid> --commit-message "Updated rules"
818
+ $ lexq versions update --group-id <gid> --id <vid> --clear-effective-to
819
819
  `
820
820
  ).action(async (opts) => {
821
821
  try {
@@ -911,6 +911,8 @@ function buildUpdateBody(opts) {
911
911
  if (opts.commitMessage) body.commitMessage = opts.commitMessage;
912
912
  if (opts.effectiveFrom) body.effectiveFrom = opts.effectiveFrom;
913
913
  if (opts.effectiveTo) body.effectiveTo = opts.effectiveTo;
914
+ if (opts.clearEffectiveFrom) body.clearEffectiveFrom = true;
915
+ if (opts.clearEffectiveTo) body.clearEffectiveTo = true;
914
916
  return body;
915
917
  }
916
918
 
@@ -1326,7 +1328,9 @@ ${data.totalElements} total \xB7 page ${data.pageNo + 1}/${data.totalPages}`);
1326
1328
  dedent6`
1327
1329
 
1328
1330
  Facts used in a version's rules that have no definition yet (read-only).
1329
- Does not block publish/deploy — register them to enable validation.
1331
+ Keys a rule only reads do not block publish/deploy; register them to enable
1332
+ validation. A SET_FACT or MUTATE_FACT targetVar must already be registered:
1333
+ saving or publishing fails with ACT-032 otherwise.
1330
1334
 
1331
1335
  Example:
1332
1336
  $ lexq facts unregistered --group-id <gid> --version-id <vid> --format table
@@ -1642,6 +1646,7 @@ function registerDeployCommands(program) {
1642
1646
 
1643
1647
  Locks the version permanently. Rules cannot be modified after publishing.
1644
1648
  A snapshot hash is generated for integrity verification.
1649
+ A version whose effective end has already passed cannot be published (P-058).
1645
1650
 
1646
1651
  Example:
1647
1652
  $ lexq deploy publish --group-id <gid> --version-id <vid> --memo "Validated via dry-run"
@@ -1672,6 +1677,8 @@ function registerDeployCommands(program) {
1672
1677
  dedent7`
1673
1678
 
1674
1679
  Takes effect immediately. The version starts receiving production traffic.
1680
+ A version whose effective start has not arrived (P-037) or whose effective end
1681
+ has passed (P-058) is rejected.
1675
1682
 
1676
1683
  Example:
1677
1684
  $ lexq deploy live --group-id <gid> --version-id <vid> --memo "Go live — v3"
@@ -1698,7 +1705,8 @@ function registerDeployCommands(program) {
1698
1705
  dedent7`
1699
1706
 
1700
1707
  Reverts to the version that was live before the current one.
1701
- Only available if the previous version is still ACTIVE.
1708
+ Only available if the previous version is still ACTIVE and its effective end
1709
+ has not passed (P-058).
1702
1710
 
1703
1711
  Example:
1704
1712
  $ lexq deploy rollback --group-id <gid> --memo "High error rate" --force
@@ -2201,7 +2209,7 @@ function registerAnalyticsCommands(program) {
2201
2209
  Use --format table for summary view.
2202
2210
  `
2203
2211
  );
2204
- sim.command("start").description("Start a new batch simulation").requiredOption("--json <body>", "Simulation request body as JSON").option("--file <path>", "Read request body from a JSON file").addHelpText(
2212
+ sim.command("start").description("Start a new batch simulation").option("--json <body>", "Simulation request body as JSON").option("--file <path>", "Read request body from a JSON file").addHelpText(
2205
2213
  "after",
2206
2214
  dedent8`
2207
2215
 
@@ -2220,7 +2228,9 @@ function registerAnalyticsCommands(program) {
2220
2228
  "maxRecords": 1000
2221
2229
  }
2222
2230
  }'
2223
-
2231
+
2232
+ $ lexq analytics simulation start --file simulation.json
2233
+
2224
2234
  Dataset types: HISTORICAL, UPLOADED, MANUAL
2225
2235
  Dataset sources: EXECUTION_LOGS, S3_BUCKET, REQUEST_BODY
2226
2236
  HISTORICAL from / to are yyyy-MM-dd, whole days in the organization time zone, both inclusive.
@@ -2228,6 +2238,9 @@ function registerAnalyticsCommands(program) {
2228
2238
  ).action(async (opts) => {
2229
2239
  try {
2230
2240
  const globalOpts = program.opts();
2241
+ if (!opts.json && !opts.file) {
2242
+ throw new Error("Request body is required. Use --json or --file.");
2243
+ }
2231
2244
  const body = resolveBody(opts);
2232
2245
  const data = await apiRequest("POST", "analytics/simulations", {
2233
2246
  apiKey: globalOpts.apiKey,
@@ -2578,7 +2591,7 @@ function registerProfileCommands(program) {
2578
2591
  program.command("profile <groupId>").description("Per-rule latency profile with relative slow-rule flags").option(
2579
2592
  "--rule <ruleId>",
2580
2593
  `Single-rule detail (distributions + ${LATENCY_WINDOW_MILLIS / 1e3}s window series)`
2581
- ).option("--version <versionId>", "Version to inspect (default: live version)").option(
2594
+ ).option("--version-id <versionId>", "Version to inspect (default: live version)").option(
2582
2595
  "--from <instant>",
2583
2596
  `Window start, ISO-8601 instant (default: ${PROFILE_DEFAULT_WINDOW_HOURS}h ago)`
2584
2597
  ).option("--to <instant>", "Window end, ISO-8601 instant (default: now)").option("--cache <state>", "Cache dimension for the rule table: HIT | MISS (default: HIT)").addHelpText(
@@ -2594,7 +2607,7 @@ function registerProfileCommands(program) {
2594
2607
  Examples:
2595
2608
  $ lexq profile <groupId>
2596
2609
  $ lexq profile <groupId> --cache MISS --from 2026-07-01T00:00:00Z
2597
- $ lexq profile <groupId> --rule <ruleId> --version <versionId>
2610
+ $ lexq profile <groupId> --rule <ruleId> --version-id <versionId>
2598
2611
  `
2599
2612
  ).action(async (groupId, opts) => {
2600
2613
  try {
@@ -2604,7 +2617,7 @@ function registerProfileCommands(program) {
2604
2617
  throw new Error(`--cache must be one of: ${ProfileCacheState.join(" | ")}`);
2605
2618
  }
2606
2619
  const params = {};
2607
- if (opts.version) params.versionId = opts.version;
2620
+ if (opts.versionId) params.versionId = opts.versionId;
2608
2621
  if (opts.from) params.from = opts.from;
2609
2622
  if (opts.to) params.to = opts.to;
2610
2623
  if (opts.cache) params.cacheState = opts.cache;
@@ -3743,13 +3756,15 @@ function registerVersionTools(server, callApi) {
3743
3756
  {
3744
3757
  title: "Update Policy Version",
3745
3758
  annotations: { readOnlyHint: false, destructiveHint: true },
3746
- description: "Update a DRAFT version. Only DRAFT versions can be modified. An omitted commitMessage keeps its value, but effectiveFrom and effectiveTo are replaced together: one you omit is cleared. To keep the effective window, send both current values (read them with lexq_versions_get).",
3759
+ description: "Update a DRAFT version. Only DRAFT versions can be modified. Omitted fields keep their current values. To remove an end of the effective window, set clearEffectiveFrom or clearEffectiveTo to true instead of sending a value.",
3747
3760
  inputSchema: z3.object({
3748
3761
  groupId: z3.string().uuid().describe("Policy group ID"),
3749
3762
  versionId: z3.string().uuid().describe("Version ID"),
3750
3763
  commitMessage: z3.string().optional().describe("New commit message"),
3751
- effectiveFrom: z3.string().optional().describe(`Effective start, ${INSTANT} Omit it and the start is cleared.`),
3752
- effectiveTo: z3.string().optional().describe(`Effective end, ${INSTANT} Omit it and the end is cleared.`)
3764
+ effectiveFrom: z3.string().optional().describe(`Effective start, ${INSTANT} Omit it to keep the current start.`),
3765
+ effectiveTo: z3.string().optional().describe(`Effective end, ${INSTANT} Omit it to keep the current end.`),
3766
+ clearEffectiveFrom: z3.boolean().optional().describe("Set to true to remove the effective start. Do not send effectiveFrom with it."),
3767
+ clearEffectiveTo: z3.boolean().optional().describe("Set to true to remove the effective end. Do not send effectiveTo with it.")
3753
3768
  })
3754
3769
  },
3755
3770
  async ({ groupId, versionId, ...body }) => callApi("PUT", `policy-groups/${groupId}/versions/${versionId}`, { body })
@@ -3829,7 +3844,12 @@ function registerRuleTools(server, callApi) {
3829
3844
  If a required key is missing, ask the user to confirm the type, isRequired, and description
3830
3845
  before calling lexq_facts_create — registering facts enables type validation, Console UI
3831
3846
  autocomplete, and the dry-run requirements analyzer.
3832
-
3847
+
3848
+ An action's targetVar (SET_FACT, MUTATE_FACT) must be a registered fact. Saving or
3849
+ publishing a rule whose targetVar is not registered fails with ACT-032, so register it
3850
+ with lexq_facts_create first. Keys a rule only reads (condition fields, refVar) may stay
3851
+ unregistered.
3852
+
3833
3853
  After saving, lexq_facts_unregistered lists any keys this version references but has not
3834
3854
  defined (non-blocking, version-wide) — use it to decide what to register.
3835
3855
 
@@ -3858,8 +3878,9 @@ function registerRuleTools(server, callApi) {
3858
3878
 
3859
3879
  Action parameter schemas:
3860
3880
  - MUTATE_FACT: { targetVar: string, operator: "ASSIGN"|"ADD"|"SUB"|"MUL"|"DIV", method: "PERCENTAGE"|"AMOUNT", operand: number, refVar?: string, rounding?: RoundingOption }
3861
- targetVar is the fact this action reads and writes. It must exist in facts at execution
3862
- time as a number — supplied as an input fact or written by a prior action in this rule.
3881
+ targetVar is the fact this action reads and writes. It must be a registered fact
3882
+ (ACT-032 otherwise), and the request must send it (P-015 otherwise). Its value must be
3883
+ a number when the action runs.
3863
3884
  A missing required fact throws (no 0 default).
3864
3885
  operand is the arithmetic operand; the unit is dictated by method (percent when
3865
3886
  PERCENTAGE, absolute amount when AMOUNT). Ranges are not constrained — negative values
@@ -3877,8 +3898,10 @@ function registerRuleTools(server, callApi) {
3877
3898
  DIV targetVar /= operand | invalid
3878
3899
  Constraints: DIV + PERCENTAGE is invalid (use MUL with the inverse). DIV + AMOUNT
3879
3900
  requires operand !== 0.
3880
- - SET_FACT: { targetVar: string, value: string|number|boolean } Creates the fact if absent
3881
- — this is the only action that does. MUTATE_FACT requires the target to already exist.
3901
+ - SET_FACT: { targetVar: string, value: string|number|boolean } targetVar must be a
3902
+ registered fact (ACT-032 otherwise). The request does not have to send it: SET_FACT is
3903
+ the only action that can write a key absent from the input facts. MUTATE_FACT requires
3904
+ its target to be present in the input facts.
3882
3905
  - BLOCK: { reason: string } Records a rejection decision. It does NOT halt rule execution —
3883
3906
  subsequent actions and subsequent winning rules still run. Enforcement is the caller's
3884
3907
  responsibility; the decision surfaces as the isBlocked fact.
@@ -3903,7 +3926,7 @@ function registerRuleTools(server, callApi) {
3903
3926
  {
3904
3927
  title: "Update Rule",
3905
3928
  annotations: { readOnlyHint: false, destructiveHint: true },
3906
- 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.`,
3929
+ 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.`,
3907
3930
  inputSchema: z4.object({
3908
3931
  groupId: z4.string().uuid().describe("Policy group ID"),
3909
3932
  versionId: z4.string().uuid().describe("Version ID"),
@@ -4082,7 +4105,7 @@ function registerFactTools(server, callApi) {
4082
4105
  {
4083
4106
  title: "Get Action Runtime Fact Metadata",
4084
4107
  annotations: { readOnlyHint: true },
4085
- 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.",
4108
+ 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.",
4086
4109
  inputSchema: z5.object({})
4087
4110
  },
4088
4111
  async () => callApi("GET", "schema/action-metadata")
@@ -4092,7 +4115,7 @@ function registerFactTools(server, callApi) {
4092
4115
  {
4093
4116
  title: "List Unregistered Facts",
4094
4117
  annotations: { readOnlyHint: true },
4095
- description: "List facts referenced by a version's rules but not yet defined (read-only \u2014 does not block publish/deploy, INV-4). 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.",
4118
+ 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.",
4096
4119
  inputSchema: z5.object({
4097
4120
  groupId: z5.string().uuid().describe("Policy group ID"),
4098
4121
  versionId: z5.string().uuid().describe("Version ID")
@@ -4125,7 +4148,7 @@ function registerDeployTools(server, callApi) {
4125
4148
  {
4126
4149
  title: "Publish Version",
4127
4150
  annotations: { readOnlyHint: false, destructiveHint: false },
4128
- description: `Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule. Undefined facts referenced by rules do not block publishing (INV-4); call lexq_facts_unregistered first to review them. Publishing fails if the version's compiled rules exceed ${MAX_SNAPSHOT_BYTES / 1024} KB.`,
4151
+ 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.`,
4129
4152
  inputSchema: z6.object({
4130
4153
  groupId: z6.string().uuid().describe("Policy group ID"),
4131
4154
  versionId: z6.string().uuid().describe("Version ID to publish"),
@@ -4139,7 +4162,7 @@ function registerDeployTools(server, callApi) {
4139
4162
  {
4140
4163
  title: "Deploy to Live",
4141
4164
  annotations: { readOnlyHint: false, destructiveHint: true },
4142
- 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.",
4165
+ 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. Versions whose effective end has passed are rejected (P-058). Undefined facts do not block deployment; use lexq_facts_unregistered to review what the version references but has not defined.",
4143
4166
  inputSchema: z6.object({
4144
4167
  groupId: z6.string().uuid().describe("Policy group ID"),
4145
4168
  versionId: z6.string().uuid().describe("Version ID to deploy"),
@@ -4155,7 +4178,7 @@ function registerDeployTools(server, callApi) {
4155
4178
  {
4156
4179
  title: "Rollback Deployment",
4157
4180
  annotations: { readOnlyHint: false, destructiveHint: true },
4158
- description: "Rollback to the previous deployed version. Only available if there is a previous version.",
4181
+ description: "Rollback to the previous deployed version. Only available if there is a previous version that is still ACTIVE and whose effective end has not passed (P-058).",
4159
4182
  inputSchema: z6.object({
4160
4183
  groupId: z6.string().uuid().describe("Policy group ID"),
4161
4184
  memo: z6.string().min(1).describe("Rollback reason (required)")
@@ -321,13 +321,15 @@ function registerVersionTools(server, callApi) {
321
321
  {
322
322
  title: "Update Policy Version",
323
323
  annotations: { readOnlyHint: false, destructiveHint: true },
324
- description: "Update a DRAFT version. Only DRAFT versions can be modified. An omitted commitMessage keeps its value, but effectiveFrom and effectiveTo are replaced together: one you omit is cleared. To keep the effective window, send both current values (read them with lexq_versions_get).",
324
+ description: "Update a DRAFT version. Only DRAFT versions can be modified. Omitted fields keep their current values. To remove an end of the effective window, set clearEffectiveFrom or clearEffectiveTo to true instead of sending a value.",
325
325
  inputSchema: z3.object({
326
326
  groupId: z3.string().uuid().describe("Policy group ID"),
327
327
  versionId: z3.string().uuid().describe("Version ID"),
328
328
  commitMessage: z3.string().optional().describe("New commit message"),
329
- effectiveFrom: z3.string().optional().describe(`Effective start, ${INSTANT} Omit it and the start is cleared.`),
330
- effectiveTo: z3.string().optional().describe(`Effective end, ${INSTANT} Omit it and the end is cleared.`)
329
+ effectiveFrom: z3.string().optional().describe(`Effective start, ${INSTANT} Omit it to keep the current start.`),
330
+ effectiveTo: z3.string().optional().describe(`Effective end, ${INSTANT} Omit it to keep the current end.`),
331
+ clearEffectiveFrom: z3.boolean().optional().describe("Set to true to remove the effective start. Do not send effectiveFrom with it."),
332
+ clearEffectiveTo: z3.boolean().optional().describe("Set to true to remove the effective end. Do not send effectiveTo with it.")
331
333
  })
332
334
  },
333
335
  async ({ groupId, versionId, ...body }) => callApi("PUT", `policy-groups/${groupId}/versions/${versionId}`, { body })
@@ -407,7 +409,12 @@ function registerRuleTools(server, callApi) {
407
409
  If a required key is missing, ask the user to confirm the type, isRequired, and description
408
410
  before calling lexq_facts_create — registering facts enables type validation, Console UI
409
411
  autocomplete, and the dry-run requirements analyzer.
410
-
412
+
413
+ An action's targetVar (SET_FACT, MUTATE_FACT) must be a registered fact. Saving or
414
+ publishing a rule whose targetVar is not registered fails with ACT-032, so register it
415
+ with lexq_facts_create first. Keys a rule only reads (condition fields, refVar) may stay
416
+ unregistered.
417
+
411
418
  After saving, lexq_facts_unregistered lists any keys this version references but has not
412
419
  defined (non-blocking, version-wide) — use it to decide what to register.
413
420
 
@@ -436,8 +443,9 @@ function registerRuleTools(server, callApi) {
436
443
 
437
444
  Action parameter schemas:
438
445
  - MUTATE_FACT: { targetVar: string, operator: "ASSIGN"|"ADD"|"SUB"|"MUL"|"DIV", method: "PERCENTAGE"|"AMOUNT", operand: number, refVar?: string, rounding?: RoundingOption }
439
- targetVar is the fact this action reads and writes. It must exist in facts at execution
440
- time as a number — supplied as an input fact or written by a prior action in this rule.
446
+ targetVar is the fact this action reads and writes. It must be a registered fact
447
+ (ACT-032 otherwise), and the request must send it (P-015 otherwise). Its value must be
448
+ a number when the action runs.
441
449
  A missing required fact throws (no 0 default).
442
450
  operand is the arithmetic operand; the unit is dictated by method (percent when
443
451
  PERCENTAGE, absolute amount when AMOUNT). Ranges are not constrained — negative values
@@ -455,8 +463,10 @@ function registerRuleTools(server, callApi) {
455
463
  DIV targetVar /= operand | invalid
456
464
  Constraints: DIV + PERCENTAGE is invalid (use MUL with the inverse). DIV + AMOUNT
457
465
  requires operand !== 0.
458
- - SET_FACT: { targetVar: string, value: string|number|boolean } Creates the fact if absent
459
- — this is the only action that does. MUTATE_FACT requires the target to already exist.
466
+ - SET_FACT: { targetVar: string, value: string|number|boolean } targetVar must be a
467
+ registered fact (ACT-032 otherwise). The request does not have to send it: SET_FACT is
468
+ the only action that can write a key absent from the input facts. MUTATE_FACT requires
469
+ its target to be present in the input facts.
460
470
  - BLOCK: { reason: string } Records a rejection decision. It does NOT halt rule execution —
461
471
  subsequent actions and subsequent winning rules still run. Enforcement is the caller's
462
472
  responsibility; the decision surfaces as the isBlocked fact.
@@ -481,7 +491,7 @@ function registerRuleTools(server, callApi) {
481
491
  {
482
492
  title: "Update Rule",
483
493
  annotations: { readOnlyHint: false, destructiveHint: true },
484
- 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.`,
494
+ 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.`,
485
495
  inputSchema: z4.object({
486
496
  groupId: z4.string().uuid().describe("Policy group ID"),
487
497
  versionId: z4.string().uuid().describe("Version ID"),
@@ -664,7 +674,7 @@ function registerFactTools(server, callApi) {
664
674
  {
665
675
  title: "Get Action Runtime Fact Metadata",
666
676
  annotations: { readOnlyHint: true },
667
- 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.",
677
+ 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.",
668
678
  inputSchema: z5.object({})
669
679
  },
670
680
  async () => callApi("GET", "schema/action-metadata")
@@ -674,7 +684,7 @@ function registerFactTools(server, callApi) {
674
684
  {
675
685
  title: "List Unregistered Facts",
676
686
  annotations: { readOnlyHint: true },
677
- description: "List facts referenced by a version's rules but not yet defined (read-only \u2014 does not block publish/deploy, INV-4). 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.",
687
+ 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.",
678
688
  inputSchema: z5.object({
679
689
  groupId: z5.string().uuid().describe("Policy group ID"),
680
690
  versionId: z5.string().uuid().describe("Version ID")
@@ -707,7 +717,7 @@ function registerDeployTools(server, callApi) {
707
717
  {
708
718
  title: "Publish Version",
709
719
  annotations: { readOnlyHint: false, destructiveHint: false },
710
- description: `Publish a DRAFT version (DRAFT \u2192 ACTIVE). Locks the version from further edits. Must have at least one rule. Undefined facts referenced by rules do not block publishing (INV-4); call lexq_facts_unregistered first to review them. Publishing fails if the version's compiled rules exceed ${MAX_SNAPSHOT_BYTES / 1024} KB.`,
720
+ 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.`,
711
721
  inputSchema: z6.object({
712
722
  groupId: z6.string().uuid().describe("Policy group ID"),
713
723
  versionId: z6.string().uuid().describe("Version ID to publish"),
@@ -721,7 +731,7 @@ function registerDeployTools(server, callApi) {
721
731
  {
722
732
  title: "Deploy to Live",
723
733
  annotations: { readOnlyHint: false, destructiveHint: true },
724
- 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.",
734
+ 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. Versions whose effective end has passed are rejected (P-058). Undefined facts do not block deployment; use lexq_facts_unregistered to review what the version references but has not defined.",
725
735
  inputSchema: z6.object({
726
736
  groupId: z6.string().uuid().describe("Policy group ID"),
727
737
  versionId: z6.string().uuid().describe("Version ID to deploy"),
@@ -737,7 +747,7 @@ function registerDeployTools(server, callApi) {
737
747
  {
738
748
  title: "Rollback Deployment",
739
749
  annotations: { readOnlyHint: false, destructiveHint: true },
740
- description: "Rollback to the previous deployed version. Only available if there is a previous version.",
750
+ description: "Rollback to the previous deployed version. Only available if there is a previous version that is still ACTIVE and whose effective end has not passed (P-058).",
741
751
  inputSchema: z6.object({
742
752
  groupId: z6.string().uuid().describe("Policy group ID"),
743
753
  memo: z6.string().min(1).describe("Rollback reason (required)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lexq/cli",
3
- "version": "1.0.2",
3
+ "version": "1.1.1",
4
4
  "mcpName": "io.lexq/lexq",
5
5
  "description": "LexQ CLI — manage policies, simulate rules, and deploy from the terminal. Built for humans and AI agents.",
6
6
  "type": "module",
@@ -41,6 +41,7 @@
41
41
  "test:decimals": "pnpm build && node tests/exact-decimals.mjs",
42
42
  "test:fact-key": "node tests/fact-key.mjs",
43
43
  "test:export-format": "node tests/export-format.mjs",
44
+ "test:command-options": "node tests/command-options.mjs",
44
45
  "test:value-domain": "pnpm build && node tests/value-domain.mjs",
45
46
  "prepublishOnly": "pnpm build",
46
47
  "knip": "knip",
@@ -253,7 +253,7 @@ lexq profile <groupId>
253
253
  lexq profile <groupId> --rule <ruleId>
254
254
 
255
255
  # Defaults: live version, last 24h, cache HIT
256
- lexq profile <groupId> --version <vid> --cache MISS \
256
+ lexq profile <groupId> --version-id <vid> --cache MISS \
257
257
  --from 2026-07-01T00:00:00Z --to 2026-08-01T00:00:00Z
258
258
  ```
259
259
 
@@ -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