Files
2026-05-27 23:29:53 +01:00

10 KiB

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<AudienceRulesErrorCode> 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<SegmentRefresherEventMap> 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"