Skip to content

feat: Full Stack v12 — safe traffic ramp-up, variation preview & experiment exclusion#45

Merged
abbaseya merged 42 commits into
mainfrom
feat/fullstack-v12
Jul 24, 2026
Merged

feat: Full Stack v12 — safe traffic ramp-up, variation preview & experiment exclusion#45
abbaseya merged 42 commits into
mainfrom
feat/fullstack-v12

Conversation

@abbaseya

Copy link
Copy Markdown
Collaborator

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.

  • Each variation is pinned at its own fixed offset (cumulative-weight anchor); its range width equals its allocation. Raising the total grows every arm at its own edge (even growth); lowering ejects evenly; nobody already bucketed gets reshuffled.
  • Gated on the experience's existing serving version property: > 11 → anchored pass (contract v12); ≤ 11 / missing → packed pass, bit-identical to today. No schema change, zero migration.
  • Hash, seed, stored-decision guard, forced path, and events are untouched. Still stateless, deterministic, and identical across every SDK — enforced by shared cross-SDK golden vectors.
  • Changing variation weights mid-flight stays unsupported (unsafe in every layout); the split lock is unchanged.

2. Experiment preview

Lets users preview any variation — including drafts and paused ones — in their own environment, with zero trace.

  • New debugToken config option (24h QA token) unlocks the full config, including draft/paused experiences.
  • Stateless preview links (web) and deep links (mobile) via convert_preview={expId}.{varId} force a single variation with no side effects: no tracking events, no visitor-state writes, no impact on reporting.
  • Rides the existing public serving primitive — no backend serving changes required.

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.

  • New audience rule bucketed_into_experience_key, resolved against SDK-stored bucketing state.
  • negated: true → "NOT in experiment X" (mutual exclusion); default → "in experiment X" (inclusion).
  • No new application inputs; evaluates before bucketing and composes identically with both packed and anchored layouts. Old SDKs fail closed.

Notes

  • Backend registration of the new rule type and the version 12 stamp, plus the related convert-ui work (preview link/QR button, rule picker) and shared docs, are tracked and coordinated separately.
  • Full existing test suites remain green; the anchored pass adds the shared golden-vector acceptance tests.

abbaseya and others added 30 commits July 4, 2026 05:38
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>
JosephSamirL and others added 10 commits July 16, 2026 01:54
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)
@abbaseya abbaseya self-assigned this Jul 16, 2026
@abbaseya
abbaseya requested a review from JosephSamirL July 16, 2026 16:27

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

@abbaseya abbaseya changed the title Full Stack v12: safe traffic ramp-up, variation preview & experiment exclusion feat: Full Stack v12 — safe traffic ramp-up, variation preview & experiment exclusion Jul 16, 2026
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>
@JosephSamirL

Copy link
Copy Markdown
Collaborator

Cross-SDK release review — ios-sdk #45

Reviewed at head 662d82d6, alongside javascript-sdk#418, ruby-sdk#43, android-sdk#56. Every claim below was read against the PR head; cross-repo claims were read on both sides.

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

Sources/ConvertSwiftSDKCore/Bucketing/BucketingManager.swift:92-103 builds the packed eligible list filtering only on variation.id:

let eligible: [WeightedVariation] = (experience.variations ?? []).compactMap { variation in
    guard let key = variation.id else { return nil }
    ...
}

There is no variation.status check. The other three all drop non-RUNNING arms:

  • JS _buildPackedBucketspackages/data/src/data-manager.ts:592-597
  • Ruby bucketable_variation?lib/convert_sdk/data_manager.rb (return false if status && status != "running")
  • Android buildPackedBucketsBucketingLayoutResolver.kt (filter { it.status == null || it.status == VariationStatuses.RUNNING })

This SDK's own anchored path does apply it (AnchoredBucketing.swift:94-95), so only the packed path diverges. That matters because packed is the v11 path, and per the JS reference's own docstring (data-manager.ts:582-584) version 11 "is the version stamped on every experience currently served in production."

Worked example — O=10% running, V1=80% stopped, V2=10% running:

bands
JS / Ruby / Android O=[0,1000), V2=[1000,2000)
iOS (this PR) O=[0,1000), V1=[1000,9000), V2=[9000,10000)

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 :86-91 cites parity with "the JS reference's packed builder … whose include filter at L568-572" — that JS filter chain is precisely where the status check lives.

Note the golden fixture can't catch this: its only v11 stopped-arm vector (total-weight-zero) has traffic_allocation: 0 on both arms, so it passes vacuously.

2. 🔴 Permanent audiences are re-evaluated for already-bucketed visitors

ExperienceManager.swift:340-375 (audiencePasses) resolves and evaluates every attached audience on every call — there is no audience-type check and no already-bucketed short-circuit in the gate itself. Searching the PR head's changed sources and the repo more broadly, the only occurrences of "permanent" are the generated ConfigAudienceTypes in ConfigSchemas.swift, the demo config, the OpenAPI spec, and the phrase "a permanent hang" in ConfigStore.swift — so as far as I can tell nothing implements it elsewhere either. Happy to be pointed at it if I've missed a call site.

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 audiences_to_check (lib/convert_sdk/data_manager.rb:801), documented there as "decided behavior 2026-06-07".

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 runExperience call — evicts them from a running experiment mid-flight. That's the exact result-skew Safe Traffic Ramp-Up is meant to prevent. android-sdk#56 has the same gap.

3. A failed re-preview leaves the previous preview stuck

ConvertContext.swift:229-241 — the guard let ... else logs and returns without touching previewState, and PreviewState.swift documents the prior value as "simply left in place."

JS clears it on failure (context.ts:172/182/195, this._preview = null) and Android clears it (ConvertContext.kt:1187/1261/1279, previewState = null). Ruby matches iOS.

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 version

BucketingManager.swift:175guard let version = experience.version, version > 11 — reads the generated public var version: Swift.Double? (Sources/ConvertSwiftSDKCore/Generated/ConfigSchemas.swift). Plain Codable synthesis cannot decode a JSON string "12".

The other three deliberately coerce: Android's BigDecimalSerializer states it reads "a JSON number (or numeric string, for loose parity with JavaScript's Number(x) coercion)"; JS uses Number(); Ruby uses Float(x, exception: false).

Conditional on the backend ever emitting the string form — but three platforms were built defensively for it and this one wasn't, so the failure would be iOS-only and look like a decode bug rather than a gate miss.

5. Deleting the packed nil-allocation guard changed explicit-null semantics

:96-101 replaces the old guard let allocation = variation.traffic_allocation else { return nil } with a default of 100.0. For an absent field that's correct JS parity (isNaN(undefined) → include at 100). For an explicit JSON null it isn't: JS excludes the arm from the packed layout (data-manager.ts:598-602) and marks it weight-0/inactive in anchored (:629-636), while iOS, Android and Ruby all now treat null as 100 % / active.

The old guard was the only mobile-side behavior matching JS here, and nothing re-establishes it. No golden vector carries an explicit null.

6. [parity] Whole-audience exclusion override — matches JS, diverges from Ruby/Android

ExperienceManager.swift:358-368: when statefulLeaf(in: groups) finds a leaf, the whole audience resolves through BucketingExclusion.resolve and ruleManager.evaluate never runs — consistent with BucketingExclusion.swift's header and with JS (data-manager.ts:1336-1345).

Ruby (rule_manager.rb:145) and Android (RuleManager.kt:299-302) instead evaluate the leaf inline so siblings still count. Needs one cross-repo decision on which is normative; flagging identically on all four PRs.

7. Minor — audience evaluation lost its short-circuit

:358 eagerly maps every attached audience to a Bool before applying ANY/ALL at :371-374, so under the default ANY semantics every remaining audience's full rule tree is still walked after the first match. One-line fix preserving laziness:

matching_options == .all ? audiences.allSatisfy(evaluate) : audiences.contains(where: evaluate)

Android has the same regression (ConvertContext.kt:1455).

Test-gap note

CI is green on this head, but the shared cross-sdk-bucketing-vectors.json exercises none of: a v11 stopped arm at nonzero allocation (item 1), an explicit-null traffic_allocation (item 5), or a string-typed version (item 4). Those three vectors would turn this review into a failing test.

Automated cross-repo review; every cited line read at the PR head SHA.

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>
@abbaseya

Copy link
Copy Markdown
Collaborator Author

Thanks Joseph — the packed-path catch (#1) was the most valuable of the set. iOS dispositions; fixes pushed in bee1a4a.

Fixed (bee1a4a)

Refuted (with evidence)

  • feat(epic-1): story 2 — Port Protocols & Core Model DTOs #2 🔴 Permanent audiences re-evaluated → mid-flight eviction. Doesn't occur on iOS. selectVariation returns the sticky/stored decision before audiencePasses runs — a bucketed visitor is never re-gated, so no audience change can evict them mid-flight. The gate function reads as you described, but the caller short-circuits ahead of it. Same as android-sdk #56.

Intentionally not changed (reasons)

Test-gap note

Agreed — and a sharp catch that the fixture's only v11 stopped-arm vector has ta:0, so #1 passed vacuously. Adding a nonzero-ta stopped-arm vector (plus the others) to the shared fixture is queued as a coordinated follow-up.

@abbaseya
abbaseya merged commit 26389d9 into main Jul 24, 2026
10 checks passed
@abbaseya
abbaseya deleted the feat/fullstack-v12 branch July 24, 2026 17:00
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