# Capability Spec Delta: audience-rules (enterprise-audience-rules) **Package**: `@suaveplan/audience-rules` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: Universal-tier purity The package SHALL run unchanged in Bun, Node, and browser environments. The `src/` tree SHALL NOT import from `node:*`, access `window` / `document` / `navigator`, or depend on any browser-only or Node-only package. #### Scenario: tier-purity grep is empty - **WHEN** the spec-conformance test walks `src/` for `from "node:`, `globalThis.document`, `navigator.userAgent` - **THEN** zero offenders are reported **Verified by:** `src/audience-rules.spec-conformance.test.ts::"src tree contains no browser or Node-only globals"` --- ### Requirement: Stable error-code registry The package SHALL expose `AUDIENCE_RULES_CODES` built via `defineCodes("AUDIENCE_RULES", [...])` and an `AudienceRulesError extends SuaveplanError` with optional `segmentId` / `tenantId` discriminants. Every failure path in the package SHALL raise an `AudienceRulesError`, never a bare `Error`. #### Scenario: every code is constructible - **WHEN** the test instantiates `new AudienceRulesError(code, "msg", { segmentId, tenantId })` for every member of `AUDIENCE_RULES_CODES` - **THEN** the resulting error narrows `code` to the namespaced string and exposes the discriminants **Verified by:** `src/error-codes/error-codes.test.ts::"every registered code constructs an AudienceRulesError with namespaced code"` AND `src/error-codes/error-codes.test.ts::"every code exposes its discriminants on the instance"` --- ### Requirement: Fourteen comparison operators `SegmentEvaluator.evaluateRule` SHALL support fourteen operators: `eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `contains`, `not_contains`, `in`, `not_in`, `exists`, `not_exists`, `starts_with`, `ends_with`. The `contains` / `not_contains` operators SHALL match both array membership and string substring. The `in` / `not_in` operators SHALL check membership in an array of values supplied as the rule value. An operator outside this set SHALL cause `evaluateRule` to throw `AudienceRulesError` with code `UNKNOWN_OPERATOR`. #### Scenario: each operator returns the documented boolean - **WHEN** the test runs every operator against a known contact / rule pair - **THEN** the result matches the expected boolean #### Scenario: unknown operator raises UNKNOWN_OPERATOR - **WHEN** `evaluateRule` is called with `operator: "unknown" as never` - **THEN** an `AudienceRulesError` with code `AUDIENCE_RULES.UNKNOWN_OPERATOR` is thrown **Verified by:** `src/segment-evaluator/segment-evaluator.test.ts::"applies every operator with the documented boolean result"` AND `src/segment-evaluator/segment-evaluator.test.ts::"raises UNKNOWN_OPERATOR for an unrecognised operator"` --- ### Requirement: AND/OR logical chaining When a `Segment` carries multiple rules, the evaluator SHALL combine them left-to-right respecting each rule's `logicalOp` (default `"and"`). The first rule's `logicalOp` is ignored; each subsequent rule combines with the running result. An empty rule list SHALL match every contact. #### Scenario: AND narrows the match set - **WHEN** a segment carries two rules combined with `logicalOp: "and"` - **THEN** only contacts matching both rules appear in the result #### Scenario: OR widens the match set - **WHEN** a segment carries two rules where the second has `logicalOp: "or"` - **THEN** contacts matching either rule appear in the result #### Scenario: empty rules match all - **WHEN** a segment carries zero rules - **THEN** the evaluator returns every contact **Verified by:** `src/segment-evaluator/segment-evaluator.test.ts::"narrows the match set when rules are AND-combined"` AND `src/segment-evaluator/segment-evaluator.test.ts::"widens the match set when rules are OR-combined"` AND `src/segment-evaluator/segment-evaluator.test.ts::"matches every contact when the rule list is empty"` --- ### Requirement: Dot-notation field resolution The evaluator SHALL resolve dot-separated paths against the contact, supporting numeric indices for array elements (e.g. `channels.0.type`). Resolution against a missing intermediate path SHALL return `undefined` (the rule does not match unless the operator is `not_exists`). #### Scenario: nested object path resolves - **WHEN** the rule field is `metadata.country` and the contact has `{ metadata: { country: "US" } }` - **THEN** the evaluator resolves to `"US"` #### Scenario: array index resolves - **WHEN** the rule field is `channels.0.type` and the contact has a single channel of type `"email"` - **THEN** the evaluator resolves to `"email"` #### Scenario: missing nested path returns undefined - **WHEN** the rule field is `a.b.c.d` and the contact does not carry `a` - **THEN** the evaluator treats the value as `undefined` **Verified by:** `src/segment-evaluator/segment-evaluator.test.ts::"resolves dot-notation paths through nested objects"` AND `src/segment-evaluator/segment-evaluator.test.ts::"resolves numeric indices into array fields"` AND `src/segment-evaluator/segment-evaluator.test.ts::"returns undefined for missing nested paths"` --- ### Requirement: Predicate factories The package SHALL export seven `SegmentRule` factories: `hasTag(tag)`, `channelIs(type)`, `statusIs(status)`, `lastActivityBefore(date)`, `lastActivityAfter(date)`, `metadataEquals(field, value)`, `metadataContains(field, value)`. Each factory SHALL return a `SegmentRule` whose `field` / `operator` / `value` produce a match consistent with the predicate's name. #### Scenario: each predicate produces the documented rule shape - **WHEN** each factory is called with a representative input - **THEN** the returned `SegmentRule` carries the documented `field` and `operator` **Verified by:** `src/predicates/predicates.test.ts::"hasTag produces a tags-contains rule"` AND `src/predicates/predicates.test.ts::"channelIs produces a channels.0.type-eq rule"` AND `src/predicates/predicates.test.ts::"statusIs produces a status-eq rule"` AND `src/predicates/predicates.test.ts::"lastActivityBefore produces a lastActivityAt-lt rule"` AND `src/predicates/predicates.test.ts::"lastActivityAfter produces a lastActivityAt-gt rule"` AND `src/predicates/predicates.test.ts::"metadataEquals produces a metadata-field-eq rule"` AND `src/predicates/predicates.test.ts::"metadataContains produces a metadata-field-contains rule"` --- ### Requirement: SegmentStore with Clock and IdGenerator injection The package SHALL expose a `SegmentStore` interface with `create`, `getById`, `update`, `delete`, `list`, `updateCache` methods and an `InMemorySegmentStore` implementation. `InMemorySegmentStore` SHALL accept optional `clock: Clock` and `idGenerator: IdGenerator` injections from `@suaveplan/types` (defaults `() => Date.now()` and a deterministic per-instance counter). Production paths SHALL NOT call `Date.now()`, `new Date()` directly, or mutate a module-scope counter. #### Scenario: injected Clock controls timestamps - **WHEN** an `InMemorySegmentStore` is constructed with `clock: () => 1_700_000_000_000` - **THEN** every `createdAt` / `updatedAt` / `lastRefreshedAt` reports `new Date(1_700_000_000_000)` #### Scenario: injected IdGenerator controls segment IDs - **WHEN** an `InMemorySegmentStore` is constructed with `idGenerator: () => "seg_fixed"` - **THEN** `store.create({...}).id === "seg_fixed"` **Verified by:** `src/store/store.test.ts::"uses injected Clock for createdAt and updatedAt"` AND `src/store/store.test.ts::"uses injected IdGenerator for segment IDs"` --- ### Requirement: Multi-tenant isolation `SegmentStore.list(tenantId)` SHALL return only segments whose `tenantId` matches the argument. Cross-tenant segments SHALL be invisible to `list` even when stored in the same instance. #### Scenario: list scopes to tenant - **WHEN** an `InMemorySegmentStore` holds segments for `"tenant-a"` and `"tenant-b"` - **THEN** `store.list("tenant-a")` returns only `tenant-a`'s segments **Verified by:** `src/store/store.test.ts::"list returns only segments for the requested tenant"` --- ### Requirement: SegmentRefresher event emission `SegmentRefresher` SHALL expose `refresh(segmentId)` and `refreshAll(tenantId)` methods. Each successful refresh SHALL update the store cache via `updateCache` and emit `segment:refreshed` on its `events: EventBus` field. `refresh` SHALL return `undefined` for an unknown segment ID without emitting. `refreshAll` SHALL emit once per refreshed segment. #### Scenario: refresh updates cache and emits - **WHEN** `refresher.refresh(segment.id)` is called for an existing segment - **THEN** the segment's `cachedContactIds` / `cachedCount` / `lastRefreshedAt` are updated and a `segment:refreshed` listener observes `{ segmentId, result }` #### Scenario: unknown segment returns undefined - **WHEN** `refresher.refresh("missing")` is called - **THEN** the result is `undefined` and no event is emitted #### Scenario: refreshAll emits once per segment - **WHEN** a tenant carries three segments and `refresher.refreshAll(tenant)` is called - **THEN** the `segment:refreshed` listener is invoked three times **Verified by:** `src/segment-refresher/segment-refresher.test.ts::"refresh updates the store cache and emits segment:refreshed"` AND `src/segment-refresher/segment-refresher.test.ts::"refresh returns undefined for an unknown segment ID"` AND `src/segment-refresher/segment-refresher.test.ts::"refreshAll emits segment:refreshed once per refreshed segment"` --- ### Requirement: Zod-validated input boundaries Every public `SegmentStore.create` / `SegmentStore.update` call SHALL validate its input via a Zod schema before persisting. Validation failure SHALL raise `AudienceRulesError` with code `INVALID_INPUT` (never a raw `ZodError`). #### Scenario: invalid create input is rejected - **WHEN** `InMemorySegmentStore.create({ name: "", tenantId: "t1", rules: [], refreshMode: "manual" })` is called with an empty name - **THEN** an `AudienceRulesError` with code `AUDIENCE_RULES.INVALID_INPUT` is thrown and no segment is persisted #### Scenario: invalid update input is rejected - **WHEN** `InMemorySegmentStore.update(id, { name: "" })` is called with an empty name - **THEN** an `AudienceRulesError` with code `AUDIENCE_RULES.INVALID_INPUT` is thrown **Verified by:** `src/store/store.test.ts::"rejects create with empty name and raises INVALID_INPUT"` AND `src/store/store.test.ts::"rejects update with empty name and raises INVALID_INPUT"`