> ## Documentation Index
> Fetch the complete documentation index at: https://docs.omneo.io/llms.txt
> Use this file to discover all available pages before exploring further.

# Building dynamic groups

> Compute group membership from a candidate query and reusable inclusion and exclusion rules, then refresh and debug it via the Omneo API.

A dynamic group computes its own membership. You define a candidate query that selects the Profiles to consider, attach inclusion and exclusion rules that decide which of those Profiles belong, and Omneo re-evaluates the group on a schedule. This guide covers the membership model, building the query and rules, creating a dynamic group in one call, refreshing it, and debugging why a Profile is or is not a member. For the model behind groups, see the [Groups concept](/concepts/groups).

All endpoints are bearer authenticated and live under `https://api.[tenant].getomneo.com/api/v3`.

## How membership is decided

A Profile is a member of a dynamic group when all three of these hold:

1. It is returned by the group's **candidate query**.
2. It matches at least one active **inclusion** rule.
3. It matches no active **exclusion** rule.

The rules combine in a fixed way:

* **Inclusion rules combine with OR.** A Profile passes the inclusion gate when it matches any one of them.
* **Exclusion rules are evaluated in order, and the first match wins.** As soon as an exclusion rule matches, the Profile is out and no further rules are checked.
* **Compose AND and NOT inside a single rule.** Put more complex logic in one rule's [JSON Logic](/concepts/glossary#j) expression rather than spreading it across several rules, because separate rules only ever OR (inclusion) or short-circuit (exclusion).

## Define the candidate query

Every dynamic group has a candidate query, set through the `query`, `query_type`, and `arguments` fields on the definition. The query is the first gate: a Profile the query does not return is never evaluated against the rules, so it can never be a member.

| Field        | Description                                                                                                 |
| ------------ | ----------------------------------------------------------------------------------------------------------- |
| `query`      | The name of the candidate query to run.                                                                     |
| `query_type` | A string identifying which query the name refers to. The set of available queries is configured per tenant. |
| `arguments`  | An array of `{ name, value }` objects passed to the query.                                                  |

A dynamic group must have a candidate query. You can change the query later, but you cannot remove it.

## Create reusable Rule Definitions

Rules on a group do not carry their own logic. Each rule points at a **Group Rule Definition**, a reusable template that holds the JSON Logic expression. Create a template once and reference it from as many groups as you like.

```shell theme={null}
curl -X POST "https://api.[tenant].getomneo.com/api/v3/groups/rule-definitions" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "handle": "has-status",
    "name": "Has status",
    "evaluator": "jsonlogic",
    "rule": { "in": [ { "var": "args.status" }, { "var": "profile.statuses" } ] },
    "default_arguments": { "status": "vip" },
    "is_active": true
  }'
```

| Field               | Required | Description                                                                       |
| ------------------- | -------- | --------------------------------------------------------------------------------- |
| `handle`            | Yes      | Handle for the template. Handles are globally unique across all Rule Definitions. |
| `name`              | Yes      | Human-readable name.                                                              |
| `evaluator`         | Yes      | The evaluator for the rule. Use `jsonlogic`.                                      |
| `rule`              | Yes      | The JSON Logic object evaluated against a Profile's data.                         |
| `default_arguments` | No       | A map of `{ "name": value }` defaults, used when a group does not override them.  |
| `is_active`         | No       | Whether the template is active.                                                   |

Because the logic lives on the template, a group that references it adjusts behaviour only through `arguments`, which override the template's `default_arguments` by key. This keeps a single expression, such as "has a given status", consistent everywhere it is used.

### Fields available to a rule

A rule's JSON Logic is evaluated against this context. Reference the fields with `var`, as in the examples on this page.

| Path               | Value                                                                                                        |
| ------------------ | ------------------------------------------------------------------------------------------------------------ |
| `profile.id`       | The Profile's id.                                                                                            |
| `profile.email`    | The Profile's email.                                                                                         |
| `profile.statuses` | An array of the Profile's status handles.                                                                    |
| `args.<name>`      | The rule's effective arguments: the template's `default_arguments` merged with the group rule's `arguments`. |

### The Rule Definition object

Reading a template with `GET /groups/rule-definitions/{id}` returns the shared logic and its metadata.

```json theme={null}
{
  "data": {
    "id": 12,
    "handle": "has-status",
    "name": "Has status",
    "description": "Matches a Profile that holds a given status.",
    "evaluator": "jsonlogic",
    "rule": { "in": [ { "var": "args.status" }, { "var": "profile.statuses" } ] },
    "default_arguments": { "status": "vip" },
    "context_requirements": [ "profile.statuses" ],
    "meta": {},
    "is_active": true,
    "created_at": "2026-06-01T09:00:00Z",
    "updated_at": "2026-06-01T09:00:00Z"
  }
}
```

| Field                       | Description                                                         |
| --------------------------- | ------------------------------------------------------------------- |
| `id`                        | Template id, referenced by a rule as `group_rule_definition_id`.    |
| `handle`                    | Globally unique handle.                                             |
| `name`                      | Human-readable name.                                                |
| `description`               | Optional description.                                               |
| `evaluator`                 | The evaluator. `jsonlogic`.                                         |
| `rule`                      | The JSON Logic object evaluated against a Profile.                  |
| `default_arguments`         | `{name: value}` defaults, used when a group does not override them. |
| `context_requirements`      | The context the rule expects to be available when it evaluates.     |
| `meta`                      | Free-form metadata object.                                          |
| `is_active`                 | Whether new rules may reference the template.                       |
| `created_at` / `updated_at` | Timestamps.                                                         |

### Update or delete a Rule Definition

Templates are shared, so a change ripples to every group that references them.

* `PUT /groups/rule-definitions/{id}` updates the template and returns `200`. Every dynamic group that references it becomes stale: its `refresh_status` moves to `stale` and it is re-evaluated on the next run.
* `DELETE /groups/rule-definitions/{id}` returns `204`. Referencing groups become stale and their rules are left without logic, so re-point those rules at another template or delete them.
* Setting `is_active: false` keeps existing rules working but blocks new rules from referencing the template.

## Attach rules to a group

A **Rule** is an instance of a template on a specific group. Add one with `POST /groups/definitions/{group}/rules`.

```shell theme={null}
curl -X POST "https://api.[tenant].getomneo.com/api/v3/groups/definitions/{group}/rules" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "inclusion",
    "group_rule_definition_id": 12,
    "arguments": { "status": "gold" },
    "sort_order": 1
  }'
```

| Field                      | Required | Description                                                                    |
| -------------------------- | -------- | ------------------------------------------------------------------------------ |
| `type`                     | Yes      | `inclusion` or `exclusion`.                                                    |
| `group_rule_definition_id` | Yes      | The template this rule uses.                                                   |
| `name`                     | No       | Optional name for the rule on this group.                                      |
| `arguments`                | No       | A map of `{ "name": value }` overrides for the template's `default_arguments`. |
| `sort_order`               | No       | Position of the rule. Determines the order exclusion rules are evaluated in.   |

A rule references a template and cannot carry its own logic. To change what a rule tests, point it at a different template or adjust its `arguments`.

A rule read returns the template reference, this rule's overrides, and an `effective` object:

```json theme={null}
{
  "data": {
    "id": 100,
    "group_definition_id": 42,
    "group_rule_definition_id": 12,
    "type": "inclusion",
    "name": "Has VIP status",
    "arguments": { "status": "vip" },
    "sort_order": 1,
    "is_active": true,
    "meta": {},
    "effective": {
      "handle": "has-status",
      "evaluator": "jsonlogic",
      "rule": { "in": [ { "var": "args.status" }, { "var": "profile.statuses" } ] },
      "name": "Has status",
      "arguments": { "status": "vip" }
    },
    "created_at": "2026-07-01T09:00:00Z",
    "updated_at": "2026-07-01T09:00:00Z"
  }
}
```

The `effective` object is the resolved view: the template's logic merged with this rule's argument overrides. Use it to confirm exactly what the rule evaluates without cross-referencing the template.

### Edit or remove a rule

Update a rule with `PUT /groups/definitions/{group}/rules/{rule}` to re-point its `group_rule_definition_id`, or change its `arguments`, `sort_order`, or `is_active`. Remove it with `DELETE /groups/definitions/{group}/rules/{rule}`, which returns `204`. Both mark the group `stale` so it is re-evaluated on the next run.

## Create a dynamic group in one call

You do not have to create the definition and its rules separately. Pass `type: "dynamic"`, the query fields, and a nested `rules` array where each rule references a `group_rule_definition_id`.

```shell theme={null}
curl -X POST "https://api.[tenant].getomneo.com/api/v3/groups/definitions" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Lapsed VIPs, excluding staff",
    "handle": "lapsed-vips",
    "type": "dynamic",
    "query": "lapsed",
    "query_type": "profiles",
    "arguments": [ { "name": "days", "value": 90 } ],
    "refresh_period": 1,
    "refresh_period_type": "days",
    "rules": [
      {
        "type": "inclusion",
        "group_rule_definition_id": 12,
        "arguments": { "status": "vip" },
        "sort_order": 1
      },
      {
        "type": "exclusion",
        "group_rule_definition_id": 20,
        "arguments": { "status": "staff" },
        "sort_order": 1
      }
    ]
  }'
```

The response is `201` with the definition and its rules. In this example a Profile is a member when the candidate query returns it (profiles lapsed for 90 days), it has the `vip` status (inclusion), and it does not have the `staff` status (exclusion).

The definition comes back with the query fields, the refresh schedule, and each rule resolved into `effective` form:

```json theme={null}
{
  "data": {
    "id": 42,
    "name": "Lapsed VIPs, excluding staff",
    "handle": "lapsed-vips",
    "type": "dynamic",
    "source_type": null,
    "source_id": null,
    "query": "lapsed",
    "query_type": "profiles",
    "arguments": [ { "name": "days", "value": 90 } ],
    "period": null,
    "period_type": null,
    "absolute_expiry": null,
    "refresh_period": 1,
    "refresh_period_type": "days",
    "refresh_absolute_expiry": null,
    "last_refreshed_at": "2026-07-14T02:00:00Z",
    "next_refresh_at": "2026-07-15T02:00:00Z",
    "refresh_status": "fresh",
    "refresh_error": null,
    "short_description": null,
    "description": null,
    "owner_profile_id": null,
    "meta": {},
    "is_published": true,
    "is_active": true,
    "is_archived": false,
    "current_member_count": 0,
    "rules": [
      {
        "id": 100,
        "group_definition_id": 42,
        "group_rule_definition_id": 12,
        "type": "inclusion",
        "name": "Has VIP status",
        "arguments": { "status": "vip" },
        "sort_order": 1,
        "is_active": true,
        "meta": {},
        "effective": {
          "handle": "has-status",
          "evaluator": "jsonlogic",
          "rule": { "in": [ { "var": "args.status" }, { "var": "profile.statuses" } ] },
          "name": "Has status",
          "arguments": { "status": "vip" }
        },
        "created_at": "2026-07-01T09:00:00Z",
        "updated_at": "2026-07-01T09:00:00Z"
      },
      {
        "id": 101,
        "group_definition_id": 42,
        "group_rule_definition_id": 20,
        "type": "exclusion",
        "name": "Has staff status",
        "arguments": { "status": "staff" },
        "sort_order": 1,
        "is_active": true,
        "meta": {},
        "effective": {
          "handle": "has-status",
          "evaluator": "jsonlogic",
          "rule": { "in": [ { "var": "args.status" }, { "var": "profile.statuses" } ] },
          "name": "Has status",
          "arguments": { "status": "staff" }
        },
        "created_at": "2026-07-01T09:05:00Z",
        "updated_at": "2026-07-01T09:05:00Z"
      }
    ],
    "created_at": "2026-07-01T09:00:00Z",
    "updated_at": "2026-07-14T02:00:00Z"
  }
}
```

`current_member_count` stays `0` on a dynamic definition even after it has members; size the group from its latest snapshot. For the full field reference, see [Working with Groups](/dev-guides/groups/working-with-groups).

<Note>
  The `query` and `query_type` values above are illustrative. The candidate queries available to you are configured per tenant, so use a query that exists in your tenant rather than copying these names.
</Note>

<Note>
  `arguments` appears at two levels with different shapes. On the definition it configures the candidate query and is an array of `{ "name", "value" }` objects. On a rule, and in a template's `default_arguments`, it is a flat `{ "name": value }` map, referenced in JSON Logic as `args.<name>`.
</Note>

## Refresh and read snapshots

Omneo re-evaluates a dynamic group on the schedule set by `refresh_period` and `refresh_period_type` (the same period enum as membership expiry: `days`, `weeks`, `months`, `years`, and the `absolute_*` variants). Each evaluation produces a snapshot that records the per-Profile result.

Start a run on demand:

```shell theme={null}
curl -X POST "https://api.[tenant].getomneo.com/api/v3/groups/definitions/{group}/snapshots" \
  -H "Authorization: Bearer ${TOKEN}"
```

The response is `202`: the run has been accepted and will complete shortly. It does not return the new membership inline.

Read the runs, newest first, and drill into a single run's per-Profile detail:

```shell theme={null}
# List runs for a group
GET /api/v3/groups/definitions/{group}/snapshots

# Per-profile detail for one run
GET /api/v3/groups/definitions/{group}/snapshots/{snapshot}/profiles
```

Each run returns a snapshot record. A finished run has `status` `completed`; a run that could not finish has `status` `failed` with an `error`.

```json theme={null}
{
  "data": {
    "id": 900,
    "group_definition_id": 42,
    "status": "completed",
    "source": "manual",
    "snapshot_at": "2026-07-15T02:00:00Z",
    "started_at": "2026-07-15T02:00:01Z",
    "completed_at": "2026-07-15T02:00:07Z",
    "failed_at": null,
    "error": null,
    "total_candidates": 300,
    "total_excluded": 60,
    "total_profiles": 180,
    "inclusion_breakdown": { "100": 240 },
    "exclusion_breakdown": { "101": 60 },
    "duration_ms": 6012,
    "meta": {},
    "created_at": "2026-07-15T02:00:00Z",
    "updated_at": "2026-07-15T02:00:07Z"
  }
}
```

| Field                                         | Description                                                                                                |
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
| `id`                                          | Snapshot id.                                                                                               |
| `group_definition_id`                         | The group this run evaluated.                                                                              |
| `status`                                      | Lifecycle state of the run. A finished run is `completed`; a run that could not finish is `failed`.        |
| `source`                                      | `manual` for an on-demand run, `scheduled` for a run the refresh cadence started.                          |
| `snapshot_at`                                 | The point in time the run represents.                                                                      |
| `started_at` / `completed_at` / `failed_at`   | When the run started, completed, or failed. Unused ones are `null`.                                        |
| `error`                                       | Failure message when `status` is `failed`, otherwise `null`.                                               |
| `total_candidates`                            | Profiles the candidate query returned.                                                                     |
| `total_excluded`                              | Candidates removed by an exclusion rule.                                                                   |
| `total_profiles`                              | Final member count for the run. There is no separate matched count; this is the number in the group.       |
| `inclusion_breakdown` / `exclusion_breakdown` | Per-rule summary of the run, keyed by rule `id`. Informational; use the `total_*` fields for exact counts. |
| `duration_ms`                                 | Run duration in milliseconds.                                                                              |
| `meta`                                        | Free-form metadata object.                                                                                 |
| `created_at` / `updated_at`                   | Timestamps.                                                                                                |

<Note>
  A `POST` to `/snapshots` returns `202` with this same record before it finishes, so `completed_at` and `total_profiles` are not yet populated. Poll the snapshot until `status` is `completed`.
</Note>

Drill into a run's per-Profile rows with the profiles endpoint above. Narrow the list with `filter[result_status]` and `filter[processing_status]`.

```json theme={null}
{
  "data": [
    {
      "id": 5001,
      "group_definition_id": 42,
      "group_snapshot_id": 900,
      "profile_id": "a1b460b2-953f-4587-b4eb-fb0f29b55e02",
      "processing_status": "processed",
      "result_status": "included",
      "matched_group_rule_ids": [ 100 ],
      "excluded_by_group_rule_id": null,
      "evaluation_error": null,
      "processed_at": "2026-07-15T02:00:05Z",
      "created_at": "2026-07-15T02:00:01Z",
      "updated_at": "2026-07-15T02:00:05Z"
    },
    {
      "id": 5002,
      "group_definition_id": 42,
      "group_snapshot_id": 900,
      "profile_id": "b2c571c3-a640-4698-c5fc-0c1a3ab66f13",
      "processing_status": "processed",
      "result_status": "excluded",
      "matched_group_rule_ids": [ 100 ],
      "excluded_by_group_rule_id": 101,
      "evaluation_error": null,
      "processed_at": "2026-07-15T02:00:05Z",
      "created_at": "2026-07-15T02:00:01Z",
      "updated_at": "2026-07-15T02:00:05Z"
    }
  ]
}
```

| Field                                       | Description                                                                                                  |
| ------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| `id`                                        | Row id.                                                                                                      |
| `group_definition_id` / `group_snapshot_id` | The group and the run this row belongs to.                                                                   |
| `profile_id`                                | The evaluated Profile.                                                                                       |
| `processing_status`                         | Whether this Profile finished evaluating in the run. Filterable with `filter[processing_status]`.            |
| `result_status`                             | The outcome for this Profile, for example `included` or `excluded`. Filterable with `filter[result_status]`. |
| `matched_group_rule_ids`                    | The inclusion rule ids that matched.                                                                         |
| `excluded_by_group_rule_id`                 | The exclusion rule id that removed the Profile, or `null`.                                                   |
| `evaluation_error`                          | An error captured while evaluating this Profile, otherwise `null`.                                           |
| `processed_at`                              | When this Profile was evaluated.                                                                             |
| `created_at` / `updated_at`                 | Timestamps.                                                                                                  |

The second row shows the model in action: the Profile matched an inclusion rule (`100`) but an exclusion rule (`101`) removed it, so it is not a member.

The current membership of a dynamic group is its most recent completed snapshot. Reading members through `GET /groups/definitions/{group}/profiles` (covered in [Working with Groups](/dev-guides/groups/working-with-groups)) returns exactly that set.

The `refresh_status` field tells you where the group is in this cycle.

| `refresh_status` | Meaning                                                            |
| ---------------- | ------------------------------------------------------------------ |
| `fresh`          | The latest run completed and membership is up to date.             |
| `stale`          | The query or rules changed, so the group is due for re-evaluation. |
| `refreshing`     | A run is in progress.                                              |
| `failed`         | The last run did not complete.                                     |

Editing the query or the rules marks the group `stale` and schedules it for the next run. Start a snapshot run yourself if you need membership updated before the schedule fires.

## Debug membership with explain

To understand why a single Profile is or is not a member without reading a whole snapshot, call the explain endpoint.

```shell theme={null}
curl "https://api.[tenant].getomneo.com/api/v3/groups/definitions/{group}/profiles/{profile}/explain" \
  -H "Authorization: Bearer ${TOKEN}"
```

The response is `200` and reports the outcome of the three gates for that Profile: whether the candidate query returned it, which inclusion rules matched, and whether an exclusion rule removed it.

```json theme={null}
{
  "data": {
    "profile_id": "a1b460b2-953f-4587-b4eb-fb0f29b55e02",
    "query": { "matched": true },
    "inclusion": {
      "matched": true,
      "matches": [
        {
          "rule_id": 100,
          "handle": "has-status",
          "name": "Has VIP status",
          "matched": true,
          "explanation": { "evaluator": "jsonlogic", "result": true }
        }
      ]
    },
    "exclusion": { "matched": false, "excluded_by": null },
    "in_group": true,
    "reason": "included"
  }
}
```

Read it in gate order.

| Field                   | Description                                                                                                                                    |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `profile_id`            | The Profile explained.                                                                                                                         |
| `query.matched`         | Whether the candidate query returned the Profile.                                                                                              |
| `inclusion.matched`     | Whether at least one inclusion rule matched.                                                                                                   |
| `inclusion.matches[]`   | Each inclusion rule evaluated, with its `rule_id`, `handle`, `name`, `matched`, and an `explanation` holding the `evaluator` and its `result`. |
| `exclusion.matched`     | Whether an exclusion rule removed the Profile.                                                                                                 |
| `exclusion.excluded_by` | The exclusion rule that removed the Profile, or `null`.                                                                                        |
| `in_group`              | Whether the Profile is a member.                                                                                                               |
| `reason`                | A summary code for the outcome (see below).                                                                                                    |

| `reason`             | Meaning                                                                            |
| -------------------- | ---------------------------------------------------------------------------------- |
| `not_in_query`       | The candidate query did not return the Profile, so the rules were never evaluated. |
| `no_inclusion_match` | The query returned the Profile, but no inclusion rule matched.                     |
| `excluded_by_rule`   | An exclusion rule matched and removed the Profile.                                 |
| `included`           | The Profile passed all three gates and is a member.                                |
| `evaluation_error`   | A rule could not be evaluated for the Profile.                                     |

For a Profile removed by an exclusion rule, `exclusion.matched` is `true` and `excluded_by` names the rule:

```json theme={null}
{
  "exclusion": {
    "matched": true,
    "excluded_by": { "rule_id": 101, "handle": "has-status", "name": "Has staff status" }
  },
  "in_group": false,
  "reason": "excluded_by_rule"
}
```

Use explain when a Profile is missing from a group you expected it in: the `reason` tells you which gate to look at, and the per-rule detail shows which inclusion or exclusion rule was responsible.

## Worked example: lapsed VIPs, excluding staff

This builds the group used throughout this guide from scratch.

<Steps>
  <Step title="Create the reusable template">
    `POST /groups/rule-definitions` with a `jsonlogic` `rule` that checks a status. The response is `201` with the template `id`, for example `12`. Reuse it for both the inclusion and exclusion rule by passing different `arguments`.
  </Step>

  <Step title="Create the group and rules in one call">
    `POST /groups/definitions` with `type: "dynamic"`, the candidate query, `refresh_period`, and a `rules` array referencing template `12` (inclusion, `status: "vip"`) and template `20` (exclusion, `status: "staff"`). The response is `201` with the definition and its rules.
  </Step>

  <Step title="Run the first snapshot">
    `POST /groups/definitions/42/snapshots` returns `202`. It requires at least one active inclusion rule, so it would return `422` on a group with none.
  </Step>

  <Step title="Read the result">
    `GET /groups/definitions/42/snapshots` lists runs newest first. When `status` is `completed`, `total_profiles` is the member count. `GET /groups/definitions/42/profiles` returns the members as `group_snapshot_profile` rows.
  </Step>

  <Step title="Debug a missing Profile">
    If a customer you expected is absent, `GET /groups/definitions/42/profiles/{profile}/explain` returns the `reason`: `not_in_query` means the candidate query did not return them, `no_inclusion_match` means no inclusion rule matched, and `excluded_by_rule` names the exclusion rule that removed them.
  </Step>
</Steps>

## Errors

| Status | When                                                                                                                                                                                                                          |
| ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `200`  | A rule or Rule Definition update succeeded.                                                                                                                                                                                   |
| `201`  | A dynamic definition or a rule was created.                                                                                                                                                                                   |
| `202`  | A snapshot run was accepted and will complete shortly.                                                                                                                                                                        |
| `204`  | A rule or Rule Definition was deleted.                                                                                                                                                                                        |
| `401`  | Missing or invalid bearer token.                                                                                                                                                                                              |
| `403`  | The token lacks the scope or permission for this group.                                                                                                                                                                       |
| `404`  | The group does not exist, or the rule or snapshot is not under the group named in the URL.                                                                                                                                    |
| `422`  | Validation failed. For example, starting a snapshot on a group with no active inclusion rule, sending membership-expiry fields (`period`, `absolute_expiry`) to a dynamic group, or trying to add or remove members manually. |

## Edge cases and gotchas

* **The candidate query is the first gate.** A Profile the query does not return can never be a member, whatever the rules say. Check `query.matched` in explain before the rules.
* **Inclusion is OR, exclusion is first-match.** Separate rules only ever OR (inclusion) or short-circuit (exclusion). Put AND and NOT logic inside a single rule's JSON Logic.
* **Changes go stale, not live.** Editing the query, a rule, or a referenced template marks the group `stale`. Membership updates on the next scheduled run, or immediately if you start a snapshot.
* **A snapshot needs an inclusion rule.** Starting a run on a group with no active inclusion rule returns `422`.
* **Dynamic groups reject manual and expiry fields.** Manual add or remove, and membership-expiry fields such as `period` and `absolute_expiry`, return `422` on a dynamic group.
* **`current_member_count` stays `0`.** Size a dynamic group from `total_profiles` on its latest completed snapshot.
* **Deleting a template breaks its rules.** A deleted Rule Definition leaves referencing rules without logic. Re-point them at another template or delete them.

## Related

* [Groups concept](/concepts/groups)
* [Working with Groups](/dev-guides/groups/working-with-groups)
* [Favourite groups](/dev-guides/groups/favourite-groups)
* [Group Rule API](/api-reference/group-rule/browse-group-rules)
* [Add Group Rule API](/api-reference/group-rule/add-group-rule)
* [Edit Group Rule API](/api-reference/group-rule/edit-group-rule)
* [Delete Group Rule API](/api-reference/group-rule/delete-group-rule)
* [Browse Group Rule Definitions API](/api-reference/group-rule-definition/get-v3groupsrule-definitions)
* [Add Group Rule Definition API](/api-reference/group-rule-definition/post-v3groupsrule-definitions)
* [Update Group Rule Definition API](/api-reference/group-rule-definition/put-v3groupsrule-definitions)
* [Delete Group Rule Definition API](/api-reference/group-rule-definition/delete-v3groupsrule-definitions)
* [Start a manual snapshot run API](/api-reference/group-snapshot/start-a-manual-snapshot-run-for-a-dynamic-group)
* [List snapshots API](/api-reference/group-snapshot/list-snapshots-for-a-dynamic-group-newest-first)
* [Explain group membership API](/api-reference/group-explain/explain-why-a-single-profile-is-or-isnt-in-a-dynamic-group)
