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

# Lists and Saved Views

> After this ships, "the twelve people who came to the launch event" is something a tenant can keep, and no segment rule could ever have expressed it.

## Context

`core` already has dynamic segments, and they cover most needs. What they cannot express is the
arbitrary: a hand-picked pilot group, the attendees of an event, the eight accounts a manager
personally handles. Those have no rule; they have a person's judgement.

Two distinct things share one feature because tenants think of them as one:

* A **static list** is explicit membership. Someone is on it because someone put them there.
* A **saved view** is a stored filter over contacts. It is a shortcut, and its membership changes as
  the data does.

The distinction matters at the moment of sending. A list is stable — the members are who they were
last week. A view is evaluated at use time and may have changed. Blurring them is how a campaign
goes to a different audience than the person expected.

## Scope *(normative)*

* `crm.lists`: static or saved view, per tenant.
* `crm.list_memberships` for static lists.
* Filter definition for saved views, reusing the segment DSL grammar.
* Adding and removing members individually and in bulk.
* Using a list as a campaign audience.
* CSV export of a list, permission-gated and audit-logged.

## Non-scope *(normative)*

* Dynamic segments, which are `core` and evaluated incrementally.
* List sharing between tenants. Never.
* Automation on list membership changes — a segment does that, and a list should not become a second
  trigger system.

## Behaviour *(normative)*

1. A list is `static` or `saved_view`, declared at creation and **immutable afterwards**. Converting
   one into the other silently changes what "the members" means.
2. Static membership is explicit, with **who added the contact and when**. That attribution is what
   makes a list defensible when someone asks why a person received a campaign.
3. A saved view stores a filter using the **segment DSL grammar** (ADR-012) rather than a second
   query language. Its membership is evaluated at read time and never materialized.
4. A saved view used as a campaign audience is evaluated **at dispatch** and the resolved membership
   is snapshotted onto the campaign run, so the audience is reproducible afterwards.
5. Removing a contact from a static list is soft: `removed_at`, retained. "Who was on this list in
   March" has to be answerable.
6. Erasure removes the contact from every list and deletes its membership rows.
7. Export is gated by its own permission and **audit-logged as bulk access**, like any other bulk
   read of personal data.
8. Bulk add is bounded and idempotent: adding the same contact twice leaves one active membership.

## Data *(normative)*

| Table                  | Key invariants                                                                                                                    |
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `crm.lists`            | `tenant_id`; `name` unique per tenant; `kind` static\|saved\_view, immutable; `filter` JSONB for views; `created_by`; `is_active` |
| `crm.list_memberships` | (`list_id`, `contact_id`) with one active row; `added_by`, `added_at`, `removed_at` nullable; retained after removal              |

## API *(normative)*

| Endpoint                                        | Class      | Permission                         | Budget            |
| ----------------------------------------------- | ---------- | ---------------------------------- | ----------------- |
| `GET/POST/PATCH /v1/crm/lists`                  | Management | `crm.lists.{read\|create\|update}` | p95 \<1 s         |
| `GET /v1/crm/lists/{id}/members`                | Management | `crm.lists.read`                   | p95 \<1 s, cursor |
| `POST /v1/crm/lists/{id}/members`               | Management | `crm.lists.update`                 | p95 \<1 s, bulk   |
| `DELETE /v1/crm/lists/{id}/members/{contactId}` | Management | `crm.lists.update`                 | p95 \<1 s         |
| `GET /v1/crm/lists/{id}/export`                 | Management | `crm.lists.export`, audited        | async             |

## Events *(normative)*

`crm.list.membership_changed`, so a campaign or an integration can react. Not offered as an outgoing
webhook by default.

## Acceptance criteria *(normative)*

1. A list's kind cannot be changed after creation.
2. Static membership records who added the contact and when.
3. A saved view's membership is evaluated at read time and never materialized.
4. A saved view used as a campaign audience snapshots its resolved membership onto the run.
5. Removal is soft: "who was on this list on a past date" is answerable.
6. Erasure removes the contact from every list.
7. Export requires `crm.lists.export` and writes a bulk-access audit row.
8. Bulk-adding the same contact twice leaves one active membership.
9. **Negative:** no list references a contact of another tenant.

## Execution

Single slice, synchronous command. Saved-view evaluation reuses the DSL evaluator from
`packages/core` — one grammar, one evaluator, one set of tests.

## Open questions

| # | Question                                                        | Decides | By              |
| - | --------------------------------------------------------------- | ------- | --------------- |
| 1 | Maximum static list size before it should be a segment instead? | Daniel  | before approval |
| 2 | Can a saved view reference a dynamic segment as a condition?    | Daniel  | before approval |

## Changelog

| Version | Date       | Change        | Why | Author                 |
| ------- | ---------- | ------------- | --- | ---------------------- |
| 0.1.0   | 2026-08-17 | Initial draft | —   | daniel + claude-opus-5 |

## Delivery record

*Not implemented yet.*
