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.
Scope (normative)
crm.lists: static or saved view, per tenant.crm.list_membershipsfor 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
coreand 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)
- A list is
staticorsaved_view, declared at creation and immutable afterwards. Converting one into the other silently changes what “the members” means. - 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.
- 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.
- 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.
- Removing a contact from a static list is soft:
removed_at, retained. “Who was on this list in March” has to be answerable. - Erasure removes the contact from every list and deletes its membership rows.
- Export is gated by its own permission and audit-logged as bulk access, like any other bulk read of personal data.
- 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)
- A list’s kind cannot be changed after creation.
- Static membership records who added the contact and when.
- A saved view’s membership is evaluated at read time and never materialized.
- A saved view used as a campaign audience snapshots its resolved membership onto the run.
- Removal is soft: “who was on this list on a past date” is answerable.
- Erasure removes the contact from every list.
- Export requires
crm.lists.exportand writes a bulk-access audit row. - Bulk-adding the same contact twice leaves one active membership.
- Negative: no list references a contact of another tenant.
Execution
Single slice, synchronous command. Saved-view evaluation reuses the DSL evaluator frompackages/core — one grammar, one evaluator, one set of tests.