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/forfrom "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 ofAUDIENCE_RULES_CODES - THEN the resulting error narrows
codeto 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
evaluateRuleis called withoperator: "unknown" as never - THEN an
AudienceRulesErrorwith codeAUDIENCE_RULES.UNKNOWN_OPERATORis 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.countryand the contact has{ metadata: { country: "US" } } - THEN the evaluator resolves to
"US"
Scenario: array index resolves
- WHEN the rule field is
channels.0.typeand 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.dand the contact does not carrya - 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
SegmentRulecarries the documentedfieldandoperator
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
InMemorySegmentStoreis constructed withclock: () => 1_700_000_000_000 - THEN every
createdAt/updatedAt/lastRefreshedAtreportsnew Date(1_700_000_000_000)
Scenario: injected IdGenerator controls segment IDs
- WHEN an
InMemorySegmentStoreis constructed withidGenerator: () => "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
InMemorySegmentStoreholds segments for"tenant-a"and"tenant-b" - THEN
store.list("tenant-a")returns onlytenant-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/lastRefreshedAtare updated and asegment:refreshedlistener observes{ segmentId, result }
Scenario: unknown segment returns undefined
- WHEN
refresher.refresh("missing")is called - THEN the result is
undefinedand 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:refreshedlistener 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
AudienceRulesErrorwith codeAUDIENCE_RULES.INVALID_INPUTis 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
AudienceRulesErrorwith codeAUDIENCE_RULES.INVALID_INPUTis 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"