> ## 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.

# Segment DSL and Incremental Evaluation

> After this ships, "customers who spent over 500 000 in the last 90 days" is a live audience that updates within seconds of a purchase, not a nightly report.

## Context

Segments are what turn stored events into something a marketer can act on, and the difference
between a segment that updates in seconds and one that updates overnight is the difference between
a campaign that lands during a flash sale and one that lands after it.

ADR-012 makes a deliberate trade: the DSL is **restricted so that incremental evaluation is
possible**. A fully general query language would be more expressive and would force a full
recomputation on every event. Restricting the grammar to conditions whose truth can change only in
known ways means a single event touches only the segments it can actually affect.

Honesty about the limit is part of the design: a segment whose conditions are not incrementally
evaluable is flagged `nightly_only` **at creation**, so the person building it knows before they
depend on it.

## Scope *(normative)*

* The versioned JSON DSL: typed profile attributes, event aggregates over a window, membership in
  other segments and tiers, AND/OR/NOT.
* Incremental evaluation on each tracked event.
* `core.segment_members` with entry and exit history.
* `core.segment.entered` and `core.segment.exited`.
* `nightly_only` detection at creation time, surfaced in the builder.
* Full per-tenant recomputation as a safety net, at the data-freshness tier.
* Prebuilt RFM templates per vertical taxonomy.

## Non-scope *(normative)*

* The segment builder UI — `frontend/console`.
* Sending to a segment — `messaging`.
* Predictive or ML-derived segments. Not planned: they are unexplainable to the person who has to
  defend the campaign.
* Extending the DSL ad hoc. A new node type is a versioned change with its own spec.

## Behaviour *(normative)*

1. The DSL is **versioned**. A segment records the DSL version it was written against, so a grammar
   change never silently reinterprets an existing definition.
2. The grammar is closed: typed attribute comparisons, event aggregates (`count`, `sum(property)`,
   `first_seen`, `last_seen`) over last-N-days or all-time, membership in another segment or tier,
   and AND/OR/NOT. FORBIDDEN: arbitrary SQL, user code, unbounded joins.
3. Segment references are **acyclic**, enforced at creation. A cycle would recompute forever.
4. Evaluation is incremental: a tracked event evaluates only the segments whose conditions
   reference that event name or the attributes it changed.
5. Membership changes emit `entered` or `exited`. Re-evaluating with no change emits **nothing** —
   a segment that re-fires on every event would make campaign triggers unusable.
6. `core.segment_members` keeps history with `entered_at` and `exited_at`. "Was this contact in the
   VIP segment when we sent that message" must be answerable.
7. A definition that cannot be evaluated incrementally is flagged `nightly_only` at creation and
   **the builder tells the user**. Discovering it later, from stale data, is the failure this rule
   prevents.
8. Full recomputation runs per tenant at the freshness tier (nightly default, DEC-G5) as a safety
   net, and its divergence from incremental state is **alerted, not silently corrected**.
9. Editing a definition creates a new version and triggers a full recomputation for that segment;
   membership under the old version stays in history.

## Data *(normative)*

| Table                  | Key invariants                                                                                                                                                                         |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `core.segments`        | `tenant_id`; `name`; `definition` JSONB; `dsl_version`; `nightly_only` BOOL; `version` integer; `is_active`                                                                            |
| `core.segment_members` | (`segment_id`, `contact_id`, `entered_at`) with `exited_at` nullable; append-only history; at most one open membership per (segment, contact); index for fast current-membership reads |

## API *(normative)*

| Endpoint                             | Class      | Permission                             | Budget                |
| ------------------------------------ | ---------- | -------------------------------------- | --------------------- |
| `GET/POST/PATCH /v1/core/segments`   | Management | `core.segments.{read\|create\|update}` | p95 \<1 s             |
| `POST /v1/core/segments/preview`     | Management | `core.segments.preview`                | p95 \<5 s, count only |
| `GET /v1/core/segments/{id}/members` | Management | `core.segments.read`                   | p95 \<1 s, cursor     |

`preview` returns a count and a sample, never the full membership, and its budget is deliberately
looser: it is a design-time tool, not a runtime path.

## Events *(normative)*

`core.segment.entered` and `core.segment.exited`, both available as outgoing webhooks and both
campaign triggers in `messaging`.

## Acceptance criteria *(normative)*

1. A purchase that crosses a segment threshold emits `entered` within 5 s p95.
2. Re-evaluating an unchanged membership emits nothing.
3. Each DSL node type has a fixture proving its incremental diff is correct.
4. A definition referencing a non-incrementally-evaluable condition is flagged `nightly_only` at
   creation, and the response says so.
5. A cyclic segment reference is rejected at creation.
6. Full recomputation reconciles a deliberately seeded drift and alerts rather than silently
   fixing it.
7. Membership history answers "was contact X in segment Y on date Z" correctly.
8. **Negative:** editing a definition does not rewrite historic membership rows.

## Execution

Asynchronous pipeline. Evaluation in `backend/workers` inside the ingestion processor pass; full
recomputation in `backend/scheduler`. The DSL evaluator lives in `packages/core` as a pure function
with exhaustive per-node fixtures.

## Open questions

| # | Question                                                                            | Decides | By              |
| - | ----------------------------------------------------------------------------------- | ------- | --------------- |
| 1 | Maximum nesting depth and node count per definition — where do we cap it?           | Daniel  | before approval |
| 2 | Do RFM templates ship with the vertical taxonomy, or as a separate installable set? | 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.*
