feat(rules): mutual-exclusion audience rule (bucketed_into_experience_key) — qs-03 - #416
Conversation
Beads: ai-driven-product-dev-xypk Agent: sdk-js Files: 1 changed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nRuleType literal Beads: ai-driven-product-dev-rv02 Agent: sdk-js Files: 3 changed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…owser gate Beads: ai-driven-product-dev-hpul Agent: sdk-js RED: data-unit 4 pass / 5 fail (seam missing); integration 2 pass / 4 fail Files: 3 test files Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ted_into_experience_key seam Beads: ai-driven-product-dev-hpul Agent: sdk-js GREEN: data-unit 9/9, full data pkg 90/90, integration 5/6 (AC3 = test-timing bug, fix next) RuleManager untouched (AC7). package.json build-churn deliberately excluded. Files: packages/data/src/data-manager.ts Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cross-instance read Beads: ai-driven-product-dev-hpul Agent: sdk-js Integration now 6/6. Test-timing bug (async DataStore flush), seam unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…bit-identical
Beads: ai-driven-product-dev-uosk
Agent: sdk-js
Rules suite 47/47 (38 existing + 9 new). All 8 rows assert RuleManager.isRuleMatched({})=false (Compatibility contract). RuleManager src untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
There was a problem hiding this comment.
Code Review
This pull request implements support for the bucketed_into_experience_key mutual-exclusion rule type, enabling audience evaluation based on whether a visitor has already been bucketed into a target experience. The changes include rule tree traversal, presence-only resolution in DataManager, and comprehensive unit, integration, browser, and compatibility tests. A high-severity issue was identified where visitorId is optional in filterMatchedRecordsWithRule but treated as required in _resolveBucketingExclusion, which could cause TypeScript compilation errors or runtime bugs when visitorId is undefined.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.



Summary
Adds one additive fullstack audience rule type,
bucketed_into_experience_key, enabling declarative mutual exclusion between experiments in the FullStack JS SDK. The rule's value is a target experience key; it matches iff the visitor is already bucketed into that experience (per the SDK's stored bucketing state), and combines withnegated: trueto express "NOT in experiment X". Zero new application inputs — it works withvisitorProperties = {}.Implements qs-03 (
_bmad-output/planning-artifacts/2026-07-02-convert-js-sdk/qs-03-mutual-exclusion-rule.md). No API/OpenAPI change — config schema is unchanged; the rule rides the existing OR/AND/OR_WHEN tree as a string-valued rule. Inert until served configs carry the new rule type (backend registration is a separate, non-blocking spec).How it works
Resolution is done entirely in
DataManager, leavingpackages/rules/src/rule-manager.tsbyte-for-byte unchanged (the strongest possible regression guarantee for the generic rules):_isBucketingExclusionRule(rules)walks the audience'sOR → AND → OR_WHENtree for abucketed_into_experience_keyrule (returnsnullfor every generic audience → the originalisRuleMatchedpath runs unchanged)._resolveBucketingExclusion(rule, visitorId):target = getEntity(rule.value, 'experiences')(by key)bucketedRaw = target exists AND getData(visitorId).bucketinghas an entry forString(target.id)— presence-only, read-only (never callsretrieveVariation, never writes, never tracks)matched = negated ? !bucketedRaw : bucketedRawbucketedRaw = false+ a warning naming the keyvisitorIdis threaded intofilterMatchedRecordsWithRule's single call site; theALL/ANYaudience-combination logic is untouched.Tasks (beads: wf-jssdk-qs03)
ERROR_MESSAGES.BUCKETING_EXCLUSION_TARGET_NOT_FOUND+ hand-authoredMutualExclusionRuleTypeenum (not in generatedtypes.gen.ts).packages/data/tests/mutual-exclusion-rule-fixture.json).DataManagerseam + full TDD (data-unit + integration + browser gate).packages/rulesregression lock: fail-closed old-SDK contract + generic rules bit-identical (AC7).Test plan / gate results
RuleManagerunchangeddata-manager.tests.tsis CI-validated — its before-hook binds hardcoded port 8090, occupied locally by an unrelated process)new_duplicated_lines_density ≤ 3%: fixtures hoisted, single parameterized loops (no cross-case copy-paste)All 8 acceptance criteria (AC1–AC8) covered and evidenced.
Acceptance criteria coverage
visitorProperties = {}RuleManagerbyte-identical, data-rest 77/77exp-zzKnown limitation (carried forward — non-blocking)
If a single audience tree ever mixed a generic rule and an exclusion rule, the exclusion rule would decide the whole audience and the generic sibling would be ignored. This is out of scope for qs-03 (no UI/backend author path exists yet — both explicit non-goals; AC6 combines via separate audiences) and is documented in code + the decision log. To be carried into the backend-registration/UI sibling specs. Confirmed non-blocking by both the decision audit (PASS) and code review (MERGEABLE, confidence 40).
Reviews
🤖 Generated with Claude Code