feat: Full Stack v12 — safe traffic ramp-up, variation preview & experiment exclusion#45
Conversation
Beads: ai-driven-product-dev-0etc Adds the 59-vector cross-SDK bucketing fixture (19 v11 packed-regression + 40 v12 anchored) shared verbatim across all Convert SDKs. sha256 9993d0e6c5719cb17a4dcbcc73af9c0cb14ee0258e7fd29cc88f44b61083dd1b. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-t6in Adds the anchored parity suite (59-vector sweep) + AC1-AC9 gate/boundary tests and a deliberately-unimplemented AnchoredBucketing.selectBucket stub so the target compiles and the new assertions fail. Packed bucket()/selectBucket untouched (additive only). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add an anchored bucketing layout selected per experience by the served `experience.version` field. When `version` is greater than 11 the SDK buckets visitors under the anchored layout; experiences at version 11 or below (and those with a missing or non-numeric version) continue to use the existing packed layout, bit for bit. Under the anchored layout each variation occupies a band anchored to its cumulative position in the allocation space rather than being packed end to end. Raising an experiment's total traffic allocation therefore admits new visitors at each arm's own edge and never relocates a visitor who was already bucketed, and lowering allocation ejects out-of-range visitors without ever flipping a retained visitor to a different arm. Stopped arms and arms with an explicit zero allocation keep their anchor position but receive zero width. The hash, seed, scaling, stored-decision precedence, forced-variation path, and event payloads are unchanged. BREAKING CHANGE: bumps the cross-SDK bucketing contract to v12. Experiences served with `version` greater than 11 are bucketed with the anchored layout, which assigns different variations than the previous packed layout for the same visitor. Experiences at version 11 or below are unaffected.
…tation Beads: ai-driven-product-dev-t6in Removes stale Phase-1 RED/stub framing from the anchored test doc comments so they describe the current implemented behavior. Comment-only; no assertions or production code changed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…id directly Beads: ai-driven-product-dev-t6in Addresses code-review feedback (behavior-preserving): - buildAllocations: drop the redundant isDefaulted term (allocation>0 already covers the NaN/absent->100 case) and use the if-let default form. - bucketVersionGated: use the resolved selectedId directly instead of selected.id ?? "" (the arm was matched on id, so it is guaranteed equal). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat!: anchored bucketing layout for traffic ramping (contract v12)
…edaction — tests (RED) Beads: ai-driven-product-dev-pjnz Agent: sdk-ios AC1/AC2/AC3 test coverage; RED = missing debugToken API (compile gap) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…edaction — implementation (GREEN) Beads: ai-driven-product-dev-pjnz Agent: sdk-ios ConvertConfiguration.debugToken; ConfigFetchService transport+cache bypass; ToLoggable debug_token redaction Full suite 343/343 green; swiftlint 0 violations Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-poqd Agent: sdk-ios AC9 table-driven parse test; RED = missing PreviewParam API Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tation (GREEN) Beads: ai-driven-product-dev-poqd Agent: sdk-ios Core/Preview/PreviewParam.swift; ASCII-decimal numeric-id validation; full suite 344/344 green Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ts (RED) Beads: ai-driven-product-dev-lkfj Agent: sdk-ios AC4/AC5 table-driven full-bypass test; RED = missing PreviewDecision API Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lementation (GREEN) Beads: ai-driven-product-dev-lkfj Agent: sdk-ios Core/Preview/PreviewDecision.swift; full-bypass structural (no store/hash inputs); full suite 345/345 green Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-l827 Agent: sdk-ios AC8 exp-fetch + PreviewState memo (injectable Clock); RED = missing fetchExperienceConfig/PreviewState Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ation (GREEN) Beads: ai-driven-product-dev-l827 Agent: sdk-ios ConfigFetchService.buildExperienceConfigURL/fetchExperienceConfig (forced low-cache, no disk write); PreviewState actor memo (60s TTL, injected Clock, sweep-on-access); full suite 351/351 green Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ion + inert — tests (RED) Beads: ai-driven-product-dev-bc6h Agent: sdk-ios AC4/AC7 + precedence + inert forced-decision path; RED = missing setPreview/previewHTTPClient wiring Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ion + inert — implementation (GREEN) Beads: ai-driven-product-dev-bc6h Agent: sdk-ios ConvertContext.setPreview (async) + forced-decision short-circuit in runExperience/runExperiences; per-context ConfigFetchService+PreviewState built in createContext (AC7 isolation, previewHTTPClient inject); join-key resolve: numeric experienceId -> config experience -> match Variation.experienceKey to run key; inert warning + normal fallthrough. Test-file swiftlint --strict cleanup (large_tuple->named struct, 5 line wraps). Full suite 358/358 green; repo-wide swiftlint --strict 0 violations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Beads: ai-driven-product-dev-y1ke Agent: sdk-ios AC6 preview lifecycle zero-trace (queue/on-disk store/uploader/decision writes) + AC7 companion; RED = 5 real assertion failures Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…EEN) Beads: ai-driven-product-dev-y1ke Agent: sdk-ios Per-context previewActive gate (PreviewState.isPreviewActive = forcedVariation != nil): AND-in !previewActive to enableTracking for bucketing enqueue; early guard in trackConversion (suppresses dedup persist + both enqueues + bus fire); persistDecision:Bool=true threaded into ExperienceManager.selectVariation(s) to suppress sticky saveDecision under preview. EventQueue.flush early-returns on empty queue (uploader.callCount==0 for free). Full suite 360/360; repo lint 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stence — tests (RED) Beads: ai-driven-product-dev-h4es Agent: sdk-ios Decision-audit round-1 fix: RED = segment setters write to shared decision store ungated under preview (AC6 §2) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…stence — implementation (GREEN) Beads: ai-driven-product-dev-h4es Agent: sdk-ios Decision-audit round-1 fix: setDefaultSegments/setCustomSegments early-return under preview (guard !previewState.isPreviewActive, mirrors trackConversion:605) -> zero visitor-state disk write under preview, AC7 preserved (no shared-state mutation). Full suite 362/362; repo swiftlint --strict 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…RED) Beads: ai-driven-product-dev-hvqm Agent: sdk-ios Decision-audit round-2 fix: RED = runFeature/runFeatures leak sticky write + .bucketing enqueue ungated under preview (AC6 §2); feature resolution preserved Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ntation (GREEN) Beads: ai-driven-product-dev-hvqm Agent: sdk-ios Decision-audit round-2 fix: FeatureManager.evaluateFeature/evaluateAllFeatures gain enableTracking/persistDecision (defaults true), forwarded to selectVariation; runFeature/runFeatures pass !previewActive (NOT combined with global network.tracking -> preserves F-171 scope asymmetry). Features still RESOLVE under preview (coherent rendering); zero sticky write + zero .bucketing enqueue. Full suite 364/364; repo swiftlint --strict 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…d in runFeature/runFeatures Beads: ai-driven-product-dev-7oz6 Agent: sdk-ios Hoist one 'let previewActive = await previewState.isPreviewActive' per feature method (derive both enableTracking + persistDecision from it) -> closes torn-gate zero-trace race under concurrent setPreview, matches runExperience/runExperiences. Extracted stateless stringified(_:) to file scope to stay within type_body_length (no suppression; matches existing in-file precedent). Full suite 364/364; repo swiftlint --strict 0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ew nits (qs-02) Address code-review findings on the experiment-preview PR (#42): 1. Zero-trace parity gap — the internal `.bucketing` observer event fired unconditionally for sibling/feature-carrier experiences while a preview was active, diverging from the JS oracle (context.ts wraps every SystemEvents.BUCKETING emit in `if (!this._preview)`) and from this PR's own suppression of `.conversion` and `.segments` under preview. Thread a new `emitBucketing: Bool = true` gate from all four ConvertContext entry points (runExperience/runExperiences/runFeature/runFeatures, as `!previewActive`) through ExperienceManager.selectVariation(s) and FeatureManager.evaluate* to the single fire site. Default `true` keeps every other call site and existing behavior unchanged. 2. Redaction regex — anchor the `debug_token` branch with a negative lookbehind `(?<![A-Za-z0-9_])` so a lookalike like `x_debug_token=` is no longer over-redacted, while a real `debug_token=<value>` in any position is still fully stripped. 3. Inert-on-bad-input — PreviewDecision.forcedVariation now returns nil for an experience with a nil/empty key or id (instead of degrading to ""), so a degenerate preview target can no longer poison the runExperiences sibling filter; setPreview treats it as bad input (warns, leaves context normal). Tests: extend AC6 zero-trace to assert zero `.bucketing` observer events under preview across experience + feature paths; add regex boundary cases and PreviewDecision empty-key/id cases. Full suite green under ThreadSanitizer (370 passed / 1 skipped / 0 failed), SwiftLint --strict clean, parity + DocC coverage gates pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…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>
feat(preview): experiment preview — debugToken + preview deep links (qs-02)
…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>
…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>
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>
feat(rules): bucketed_into_experience_key mutual-exclusion audience rule (qs-03)
There was a problem hiding this comment.
Code Review
This pull request implements experiment-preview capabilities and mutual-exclusion rules (bucketed_into_experience_key) in the Convert Swift SDK, alongside support for the anchored bucketing layout (contract v12+). Key additions include a new PreviewState actor for in-memory preview memoization, a dedicated BucketingExclusion seam for whole-audience mutual exclusion, and zero-trace tracking/persistence hardening under preview. Additionally, a degrading audience decoding mechanism is introduced to prevent config-wide decode failures. A critical issue was identified in the audience decoding loop, where a decoding failure could result in an infinite loop because the container index is not advanced; a fix is suggested to safely advance the index on failure.
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.
Host-app reference wiring for experiment-preview deep links in the
ConvertSwiftSDKDemo app. Link registration is a host-app concern the SDK
scopes out; the demo now demonstrates it end to end.
A `convertdemo://…?convert_preview={experienceId}.{variationId}` link is
delivered via SwiftUI `.onOpenURL` to `DemoViewModel.applyPreviewLink`, which
parses it with the SDK's `PreviewParam.parse` and forces the variation via
`ConvertContext.setPreview`, then re-runs experiences so the forced result
renders on the Experiences screen. Fully inert on a missing/malformed link.
- Info.plist (new): register the `convertdemo` URL scheme. GENERATE_INFOPLIST_FILE
can't express CFBundleURLTypes, so switch to a real Info.plist.
- project.pbxproj / project.yml: point INFOPLIST_FILE at it (GENERATE_INFOPLIST_FILE=NO),
kept consistent so `xcodegen generate` reproduces it.
- DemoViewModel+Preview.swift (new): applyPreviewLink + os.Logger diagnostics.
- README (new): "Testing experiment-preview deep links" with the simctl steps.
Verified end-to-end in the iOS 26 Simulator: openurl → Open → the previewed
experience renders its forced variation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cross-SDK release review — ios-sdk #45Reviewed at head Leading with the one I'd treat as a merge blocker: it changes bucketing on experiences running in production today, not behind the v12 gate. 1. 🔴 Packed path no longer excludes stopped variations
let eligible: [WeightedVariation] = (experience.variations ?? []).compactMap { variation in
guard let key = variation.id else { return nil }
...
}There is no
This SDK's own anchored path does apply it ( Worked example —
A visitor at bucket value 4957 gets no variation elsewhere and the stopped variation V1 here; one at 1500 gets V2 elsewhere and V1 here. The comment at Note the golden fixture can't catch this: its only v11 stopped-arm vector ( 2. 🔴 Permanent audiences are re-evaluated for already-bucketed visitors
JS filters permanent audiences out once the visitor is bucketed: // packages/data/src/data-manager.ts:393-396
audiencesToCheck = audiences.filter(
(audience) => !(isBucketed && audience.type === ConfigAudienceTypes.PERMANENT)
);Ruby does the same in Consequence: a bucketed iOS visitor is re-gated on every call, so a permanent audience that stops matching — a time window, a first-session audience, or simply attributes not re-supplied on a later 3. A failed re-preview leaves the previous preview stuck
JS clears it on failure ( So: QA previews variation X, then opens a second shared link whose variation was deleted. JS/Android revert to normal decisions and resume tracking; iOS keeps forcing the old variation and keeps zero-trace suppression on for the context's whole lifetime — every conversion, bucketing event and visitor-state write silently dropped, with only a warn log. 4. The anchored version gate can't read a string-typed
|
Cross-SDK release-review parity fixes (JS oracle): the packed (v11) bucketing layout now excludes non-RUNNING variations; a failed re-preview resolution clears any prior preview target. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Thanks Joseph — the packed-path catch (#1) was the most valuable of the set. iOS dispositions; fixes pushed in Fixed (
|
Full Stack v12 — next major SDK release
This branch delivers three major capabilities, shipped consistently across all Full Stack SDKs. All three are inert until activated on the backend/UI side, so this deploys safely in any order.
1. Anchored (ramping) bucketing layout
Makes changing an experiment's total traffic allocation safe on a running experiment.
versionproperty:> 11→ anchored pass (contract v12);≤ 11/ missing → packed pass, bit-identical to today. No schema change, zero migration.2. Experiment preview
Lets users preview any variation — including drafts and paused ones — in their own environment, with zero trace.
debugTokenconfig option (24h QA token) unlocks the full config, including draft/paused experiences.convert_preview={expId}.{varId}force a single variation with no side effects: no tracking events, no visitor-state writes, no impact on reporting.3. Mutual exclusion / inclusion audience rule
Lets teams control which experiments a visitor can be in at the same time, through the normal audience-targeting rules.
bucketed_into_experience_key, resolved against SDK-stored bucketing state.negated: true→ "NOT in experiment X" (mutual exclusion); default → "in experiment X" (inclusion).Notes
version12 stamp, plus the related convert-ui work (preview link/QR button, rule picker) and shared docs, are tracked and coordinated separately.