Skip to content

feat(rules): mutual-exclusion audience rule (bucketed_into_experience_key) — qs-03 - #416

Merged
JosephSamirL merged 6 commits into
feat/fullstack-v12from
feat/mutual-exclusion-rule
Jul 16, 2026
Merged

feat(rules): mutual-exclusion audience rule (bucketed_into_experience_key) — qs-03#416
JosephSamirL merged 6 commits into
feat/fullstack-v12from
feat/mutual-exclusion-rule

Conversation

@abbaseya

@abbaseya abbaseya commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

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 with negated: true to express "NOT in experiment X". Zero new application inputs — it works with visitorProperties = {}.

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

Stacked PR — base is feat/experiment-preview (qs-02), NOT main.

How it works

Resolution is done entirely in DataManager, leaving packages/rules/src/rule-manager.ts byte-for-byte unchanged (the strongest possible regression guarantee for the generic rules):

  • _isBucketingExclusionRule(rules) walks the audience's OR → AND → OR_WHEN tree for a bucketed_into_experience_key rule (returns null for every generic audience → the original isRuleMatched path runs unchanged).
  • _resolveBucketingExclusion(rule, visitorId):
    • target = getEntity(rule.value, 'experiences') (by key)
    • bucketedRaw = target exists AND getData(visitorId).bucketing has an entry for String(target.id)presence-only, read-only (never calls retrieveVariation, never writes, never tracks)
    • matched = negated ? !bucketedRaw : bucketedRaw
    • unknown target key → bucketedRaw = false + a warning naming the key
  • visitorId is threaded into filterMatchedRecordsWithRule's single call site; the ALL/ANY audience-combination logic is untouched.

Tasks (beads: wf-jssdk-qs03)

  • SDK-1 — enums: ERROR_MESSAGES.BUCKETING_EXCLUSION_TARGET_NOT_FOUND + hand-authored MutualExclusionRuleType enum (not in generated types.gen.ts).
  • SDK-2 — shared 8-row contract fixture (packages/data/tests/mutual-exclusion-rule-fixture.json).
  • SDK-3DataManager seam + full TDD (data-unit + integration + browser gate).
  • SDK-4packages/rules regression lock: fail-closed old-SDK contract + generic rules bit-identical (AC7).

Test plan / gate results

  • Data-unit (8-row fixture + completeness guard): 9/9
  • Rules suite (38 existing + 9 fail-closed lock): 47/47 — AC7, RuleManager unchanged
  • Full data package: 77/77 (data-manager.tests.ts is CI-validated — its before-hook binds hardcoded port 8090, occupied locally by an unrelated process)
  • js-sdk full Playwright project (integration + browser): 47/47, including the browser gate (8-row equivalence through the real UMD bundle in headless Chromium)
  • SonarCloud 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

AC Coverage
AC1 fixture data-unit 8-row loop + browser 8-row equivalence
AC2 e2e exclusion integration: run A → B null; fresh visitor buckets B
AC3 DataStore persistence integration: instance #1 → new instance #2 via shared DataStore (row 8)
AC4 no new inputs all paths run with visitorProperties = {}
AC5 read-only spies: no target bucketing, no store writes, no track events
AC6 combination ALL (both) / ANY (either), 3-case table incl. negative control
AC7 regression lock rules 47/47, RuleManager byte-identical, data-rest 77/77
AC8 unknown-target warning rows 6/7 warn naming exp-zz

Known 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

  • Decision audit: PASS — independent analyst→auditor pass over 31 decisions, all grounded.
  • Code review: MERGEABLE — no findings at confidence ≥ 75.

🤖 Generated with Claude Code

abbaseya and others added 6 commits July 8, 2026 13:46
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>
@sonarqubecloud

sonarqubecloud Bot commented Jul 8, 2026

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/data/src/data-manager.ts
@abbaseya abbaseya self-assigned this Jul 8, 2026
@abbaseya
abbaseya requested a review from a team July 8, 2026 12:41
Base automatically changed from feat/experiment-preview to feat/fullstack-v12 July 15, 2026 23:07
@JosephSamirL
JosephSamirL merged commit fc54dc0 into feat/fullstack-v12 Jul 16, 2026
1 check passed
@JosephSamirL
JosephSamirL deleted the feat/mutual-exclusion-rule branch July 16, 2026 09:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants