feat(rules): bucketed_into_experience_key mutual-exclusion audience rule (qs-03)#43
Conversation
…tests (RED) Beads: ai-driven-product-dev-02i3 Agent: sdk-ios 5 failing tests (swift-testing): sibling audiences must survive a single unknown- discriminator leaf (regression lock for the project-wide fail-open), bad audience preserved, happy-path parity + sentinel-infra evidence pass. All failures trace to the whole-array try? nulling audiences (ProjectConfig.swift:155-158). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…implementation (GREEN) Beads: ai-driven-product-dev-02i3 Agent: sdk-ios Per-audience degrading decode (ProjectConfig+AudienceDecoding.swift) mirrors the DegradingExperience never-throws loop-termination invariant; a bad/unknown audience degrades ALONE (siblings survive, array never nulled), preserved as a placeholder ConfigAudience (id/key/name recovered from captured JSON) with the failed rule sub-tree sentinel-captured in a new degradedAudienceSentinels: [String: JSONValue] map keyed by id for IOS-2. NO generated code touched. Fixes the project-wide fail-OPEN regression. 5 targeted + 204 core tests pass; swiftlint --strict clean (incl. test-file line_length fix). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-v18s Agent: sdk-ios 3 swift-testing files pinning the M2-unit contract (fail-to-compile on absent symbols StatefulRuleTarget / RuleCondition.statefulTarget / RuleManager resolver param / RuleAdapter.flatten(JSONValue)): AC1 8-row fixture (table-driven, id-keyed), AC8 warn-on- unknown-target-only, AC6-unit intra-audience ALL/ANY, AC7 typed-vs-JSON generic regression, RuleAdapter JSON-sentinel flatten. Decision-log updated (7 entries). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-v18s
Agent: sdk-ios
StatefulRuleTarget{ruleType,targetExperienceKey} + RuleCondition.statefulTarget (defaulted);
RuleManager.evaluate gains defaulted three-state resolver ((String)->Bool?) — stateful leaf
bypasses Comparisons, warn-on-unknown-target (AC8), negation in-branch; generic path
bit-identical (AC7). RuleAdapter+JSONSentinelFlatten flattens a rule tree from the IOS-1
captured sentinel JSON, reusing the shared make() builder (no forked OR/AND/OR_WHEN semantics).
NO generated code touched. 8 new + 212 core tests pass (swift test); swiftlint --strict clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tests (RED) Beads: ai-driven-product-dev-jmr4 Agent: sdk-ios 7 Core-level end-to-end tests via public selectVariation (no new symbols). 4 discriminating failures: fresh visitor must bucket into exp-b (today's fail-closed degrade wrongly returns nil), AC6 ALL/ANY intra-audience combo, AC8 unknown-target warning. 3 pass today as GREEN regression pins (bucketed-into-A excluded, AC5 read-only, AC3 relaunch — coincide with today's unconditional degrade-fail-closed). Full core suite 219 tests, only these 4 issues. swiftlint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…implementation (GREEN) Beads: ai-driven-product-dev-jmr4 Agent: sdk-ios audiencePasses now async: pre-fetches DecisionStore.bucketingDecisions once (pure read, AC5), builds a sync (String)->Bool? resolver (config.fullExperience(forKey:) for known/unknown + String(target.id) membership in the id-keyed snapshot), dispatches degraded audiences via ExperienceManager+MutualExclusion.flattenDegradedAudienceRules (RuleAdapter.flatten(JSONValue)) vs the byte-identical typed path. Public selectVariation signature unchanged. 7/7 IOS-3 + 219 core tests pass; swift build clean; swiftlint --strict clean. NO generated code / bucketing / storage / event-payload change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nel flatten path Beads: ai-driven-product-dev-v18s Agent: sdk-ios Decision-audit finding 2: add a generic_text_key_value vector (key:browser, contains:chrome) to the typed-vs-JSON RuleAdapter.flatten equivalence test, locking the key-off-explicit-key routing for the fullstack-served key-value family on the new degraded-audience JSON path. Test-only; 4 cases pass; 219 core tests pass; swiftlint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…est (RED) Beads: ai-driven-product-dev-y1qd Agent: sdk-ios Review R1: add a 5th typed-vs-JSON bit-identity vector for bucketed_into_experience — an UNMAPPED family the typed path degrades (matchType ""/negation false) but the JSON path currently evaluates live (equals/negated:true). Assertion FAILS, proving the divergence; 4 covered-family cases still pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mplementation (GREEN) Beads: ai-driven-product-dev-y1qd Agent: sdk-ios Review R1 fix: condition(fromSentinelLeaf:) now guards make() behind a namedFamilyRuleTypes allowlist mirroring the typed condition(fromAudienceLeaf:) switch; any unmapped rule_type (e.g. bucketed_into_experience) returns degraded() => fails closed IDENTICALLY on both paths, never wrong-positive (AC7 + fail-closed invariant restored). 5-case bit-identity suite green, 219 core tests green, swiftlint clean. NO generated code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ctor (review R2) Beads: ai-driven-product-dev-t2mz Agent: sdk-ios Comment-only: the bucketed_into_experience vector comment described pre-fix DIVERGENCE; now states the family is unmapped on BOTH paths (typed default: degraded() + JSON namedFamilyRuleTypes guard) and the vector locks that CONVERGENCE against re-introducing the R1 over-cover. No code, assertion, or fixture change. 5 cases pass; swiftlint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Warning You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again! |
…rs (CI swiftlint type_name) Beads: ai-driven-product-dev-zql2 Agent: sdk-ios CI 'Build, Lint & Test' failed: repo-wide swiftlint --strict flagged type_name (42>40) on MutualExclusionRuleAdapterJSONFlattenTests. Renamed type -> MutualExclusionJSONFlattenTests (31) + file renamed (git mv); stale doc-comment refs in 2 siblings updated. Repo-wide swiftlint clean (0/179), build clean, 15 MutualExclusion tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Reviewed the full diff plus the surfaces it touches (decode seam, rule engine, EM integration), cross-checked against the qs-04 spec of record, the JS SDK's feat/mutual-exclusion-rule branch, the generated schema, and the backend's OpenAPI + real config fixtures.
The fail-open fix is real and worth the PR on its own. I verified the claim rather than taking it: on feat/fullstack-v12, audiences decoded through a whole-array try?, and audiencePasses returns true on an empty resolved set — so a single unknown rule_type discriminator really did serve every audience-gated experience to everyone. Correctly fixed, and the per-element degrade + placeholder reconstruction keeps audience(id:) resolving. Worth noting this also makes the spec's Compatibility story true on iOS: it claims old SDKs fail closed on the new rule type, which was the opposite of the truth here.
Also verified and holding up:
- The 8-row resolution fixture matches the spec's normative table row-for-row, including unknown-target → warn + vacuous-true under negation.
- AC5 read-only:
bucketingDecisions(forStoreKey:)isstore[storeKey]?.bucketing ?? [:]— no LRU touch, no write, and untouched by this PR. - The
namedFamilyRuleTypesallowlist genuinely mirrors the typed switch (22 text + country + 6 numeric + 3 bool + 4 singletons, plus the 3 key-value families) — checked case by case. - The loop-termination invariant holds:
SentinelWrapped.init's uncaughttry JSONValue(from:)can't throw, becauseJSONDecoderonly ever hands it well-formed nodes. The comment's caveat is accurate.
Findings
1. AC6 asserts rule semantics that contradict both the generated schema and the JS SDK. (inline on MutualExclusionFixtures.swift / MutualExclusionExperienceManagerTests.swift)
OR_WHEN is an OR. The generated RuleObject doc says so — "For this OR_WHEN block to be true, any of its individual RuleElement conditions must evaluate to true" — and JS implements exactly that (_processORWHEN returns on the first matching leaf; _processAND requires all OR_WHEN blocks). The allOfRulesJSON fixture puts both leaves in one OR_WHEN block and then asserts AND behavior. The fixture fix is one line and makes the test both pass and be correct.
The underlying collapse in RuleAdapter.flatten is pre-existing (present on main, untouched here) and affects production configs today, independent of qs-04 — e.g. test-config-spa-visual.json carries OR_WHEN: [url contains "statistics", url contains "pricing", url contains "events"], which this collapse turns into "contains all three at once" → effectively never matches. Multi-leaf OR_WHEN blocks appear in ~38 backend config fixtures. I'd file that separately rather than expand this PR: the flat two-level [RuleGroup] DNF model can't represent OR(AND(OR)) without distributing, so the real fix is an Epic-3-level change.
2. Composition semantics — align with the JS SDK, which is the agreed reference. (inline on ExperienceManager.swift)
Correcting myself: an earlier revision of this review said the spec's contract is leaf-level and that "JS is the side that should move." That was wrong, and I'm retracting it. The JS spec explicitly frees the seam — "Implementation seam is free (extend literal-mode resolution for this rule_type, wrap data in a provider, or pre-resolve — implementer's choice)" — which sanctions exactly what JS does. And the "identical ALL/ANY combination" clause I leaned on means matching_options.audiences, not the rule tree: the spec's Verified Facts cite matching_options.audiences supports ALL/ANY, AC2 says "a transient audience (matching_options: ALL)", and Compatibility says "Under ANY-combination with another matching audience". All audience-level.
convertcom/javascript-sdk#416 is the reference implementation to follow. Its model:
- A dedicated exclusion audience carrying exactly one
bucketed_into_experience_keyrule (documented assumption: at most one per audience) — not an exclusion leaf mixed with generic leaves in one tree. - Composition happens between audiences, via
matching_options.audiences: ALL/ANY. - Resolution is pre-resolved at the data layer before the tree reaches the rule engine, leaving the rule engine byte-unchanged (AC7 by construction).
Against that reference this PR diverges on two axes, and finding 3 is the blocker for the first:
- AC6 is tested on the wrong axis. The mixed-tree fixtures (
allOfRulesJSON/anyOfRulesJSON, one audience with two leaves) exercise a shape the reference model never produces. AC6 should be two audiences — an exclusion audience plus a generic one — asserted undermatching_options: ALLandANY, as JS #416 does. - That test can't be written here yet, because
matching_options.audiencesisn't implemented (finding 3). Landing finding 3 is the prerequisite for matching the reference.
The in-place leaf evaluation itself doesn't need to change — under the dedicated-single-rule-audience contract, in-place and pre-resolve produce identical results. It's the tested shape and the missing matching_options support that need to move.
3. matching_options.audiences: ALL is ignored — the spec's recommended safe configuration is a no-op.
The spec's Compatibility section says: "Under ANY with another matching audience it runs without exclusion. The guide must recommend ALL and state the minimum SDK version." So the safety story depends on ALL working.
audiencePasses hardcodes a flat OR across attached audiences and never reads matching_options — documented as deferred (bd-d4p: "The per-audience ALL/ANY matching_options semantics are DEFERRED"). The field is real and served (ConfigExperienceSettings.matching_options.audiences, values all/any), and JS honors it (packages/data/src/data-manager.ts:418-421).
Concretely: a customer follows the guide, attaches the exclusion audience alongside a targeting audience under ALL; iOS ORs them; the targeting audience matches, so the visitor is eligible even though the exclusion audience returned false — both experiments run and the data is contaminated. That's the failure the spec attributes only to old SDKs. AC2 passes only because its exclusion audience is the experience's sole audience, where ANY ≡ ALL.
This is now the blocker for aligning with the reference (finding 2): JS #416's whole model rests on a dedicated exclusion audience combined with others via matching_options.audiences, and iOS cannot express that until this is honored. bd-d4p predates this PR so it isn't strictly this PR's to fix — but it can no longer be deferred silently, because the guide is about to recommend exactly the configuration iOS ignores.
Traceability
The spec of record is ai-driven-product-dev#86 → _bmad-output/implementation-artifacts/2026-06-09-convert-ios-sdk/**qs-04**-mutual-exclusion-rule.md. The PR body and the test-file headers cite _bmad-output/planning-artifacts/2026-06-09-convert-ios-sdk/qs-03-mutual-exclusion-rule.md, which is off by both folder and number — worth fixing the references so the next reader can find it.
One spec-side nit while it's still unmerged: the algorithm is labelled "normative — identical across all six SDKs" but hard-codes target.id.toString(), which is the JS SDK's storage shape rather than a cross-SDK invariant. Wording it as "the SDK's native bucketing-map key shape" would stop SDKs with different storage from being structurally non-compliant while behaving correctly.
Minor
locations/segments/featuresstill decode through a whole-arraytry?— the same fail-open class fixed here for audiences, latent until a new rule type lands in a location tree.RuleElementAudienceOrSentinelis declared inPolymorphicSentinels.swiftbut never used. Leaf-level sentinelling would drop the hand-synced allowlist entirely — it'd need a hand-authored degradingRuleObjectAudiencedecode so possibly not cheaper, but worth a look given the allowlist has to track the typed switch by hand forever.
Leaving as a comment rather than blocking: the substantive ask is the one-line fixture fix in finding 1; findings 2 and 3 are platform-level calls that aren't mine to make.
Beads: ai-driven-product-dev-z0jn Agent: sdk-ios Rework to whole-audience-override + matching_options ALL/ANY (JS data-manager.ts parity). AC1 8 rows FROZEN, re-pointed to new BucketingExclusion.resolve seam (compile-RED). AC6 reworked to TWO-audience shape: ALL fails today (current code ignores matching_options, OR-concatenates); ANY passes as a non-discriminating pin. Removed mixed-tree allOfRulesJSON/anyOfRulesJSON + the two AC6-unit RuleManager tests. AC2/AC3/AC5/AC8 re-pointed to singleLeafRulesJSON; AC7 stays green. experienceJSON gains optional matching_options emit. Citation qs-03 planning -> qs-04 implementation (#86) across all test headers (0 hits remain). Supersedes the earlier user-confirmed 'AC6 intra-audience only' scope per this session's explicit directive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tation (GREEN) Beads: ai-driven-product-dev-z0jn Agent: sdk-ios Whole-audience-override exclusion + matching_options ALL/ANY (JS data-manager.ts parity): - BucketingExclusion.swift (new): resolve(targetExperienceKey:negated:resolver:logger:) mirrors _resolveBucketingExclusion (target by KEY, id-keyed membership, unknown->warn+false, negation last). - ExperienceManager.audiencePasses reworked: per-audience match (exclusion audience via whole-audience override detecting the statefulTarget leaf from the degraded sentinel; generic audience via typed RuleAdapter.flatten + RuleManager.evaluate), composed by full.settings.matching_options.audiences (.all=>all matched; .any/nil=>any); empty=>true. Replaces the old flat outer-OR that ignored the field. - RuleManager.evaluate DROPS resolvingBucketedIntoExperienceKey param + stateful branch -> generic-only (AC7 bit-identical); header reworded to not overclaim rule-manager.ts parity, cites OR_WHEN issue iefd. - namedFamilyRuleTypes KEPT (still on the detection flatten path + AC7 lock). M1 degrading decode preserved. - Fixed a genuine AC6:ALL test bug (missing exp-a bucketing setup; verified independent of prod via git stash). AC1 8 rows frozen (now via BucketingExclusion.resolve). 13 MutualExclusion + 217 core pass; repo-wide swiftlint --strict clean (180 files); swift build clean. NO generated code touched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review addressed — re-architected to full JS #416 parity (head
|
Summary
Adds the additive audience rule type
bucketed_into_experience_keyto the iOS SDK, enabling declarative mutual exclusion on fullstack, and aligns it to the JavaScript SDK reference (#416): an audience whose value is a target experience KEY matches iff the visitor is already bucketed into that experience per the SDK's stored (id-keyed) visitor bucketing state, combining withnegated: trueto express "NOT in experiment X". Read-only, evaluated before bucketing — never computes hashes, never writes, never tracks. Zero new application inputs. Cross-session exclusion is automatic via native persistence.Spec of record:
_bmad-output/implementation-artifacts/2026-06-09-convert-ios-sdk/qs-04-mutual-exclusion-rule.md(issue #86).Stacked PR — base is
feat/fullstack-v12(NOTmain).Model (bit-faithful to
javascript-sdkdata-manager.ts)bucketed_into_experience_keyleaf is an exclusion audience; its whole result is the exclusion resolution and sibling leaves are NOT evaluated (mirrors_isBucketingExclusionRule+filterMatchedRecordsWithRule). Resolution (BucketingExclusion.resolve) mirrors_resolveBucketingExclusion: target by KEY → id-keyed membership in stored bucketing → unknown target warns + false → negation applied last. Read-only.matching_options.audiencesALL/ANY at the experience level (mirrorsmatchRulesByField): each attached audience is matched independently (exclusion via override, generic via the rule engine), then composed — ALL ⇒ every audience must match; ANY/absent ⇒ at least one; empty ⇒ unrestricted. (Previously iOS OR-ed all audiences and ignored the field, making the spec's recommendedALLsafety config a no-op.)What changed (all in
ConvertSwiftSDKCore, no generated code)ProjectConfig+AudienceDecoding.swift) — the generatedRuleElementAudiencethrows on the unknown discriminator; without this, a whole-arraytry?would null all audiences → a project-wide fail-OPEN. Now a bad audience degrades alone (siblings survive), its tree sentinel-captured intodegradedAudienceSentinels.Rules/BucketingExclusion.swift(new) +Experience/ExperienceManager+MutualExclusion.swiftdetection (statefulLeaf) +ExperienceManager.audiencePassesreworked to per-audience match +matching_optionscomposition.RuleManager.evaluatedropped its in-tree resolver param and stateful branch — back to a generic-only evaluator (generic path bit-identical, AC7). Its header comment was corrected to not overclaimrule-manager.tsparity (holds only for single-leafOR_WHEN; references the filedOR_WHEN-collapse issue).Acceptance criteria (all green)
AC1 8-row fixture (frozen) · AC2 end-to-end exclusion · AC3 cross-relaunch persistence · AC4 empty attributes · AC5 read-only (spied) · AC6 two-audience combination under ALL and ANY (reworked to the JS #416 shape — a dedicated exclusion audience + a generic audience) · AC7 generic-rule regression lock (typed + JSON paths bit-identical) · AC8 unknown-target warning names the key.
Quality gates
swift buildclean; ConvertSwiftSDKCore suite green (217 tests); repo-wideswiftlint --strictclean; generated code byte-unchanged;ruleNegationBool knob unchanged.Non-goals honored
No
bucketed_into_variation_key, no exclusion groups/layers, no UI, no backend registration, no OpenAPI, no bucketing/storage-format/event-payload change, noruleNegation-knob-type change.🤖 Generated with Claude Code