Skip to main content

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)

API (normative)

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

Changelog

Delivery record

Not implemented yet.