Skip to content

feat(preview): experiment preview — debugToken + preview deep links (qs-02)#42

Merged
JosephSamirL merged 18 commits into
feat/fullstack-v12from
feat/experiment-preview
Jul 15, 2026
Merged

feat(preview): experiment preview — debugToken + preview deep links (qs-02)#42
JosephSamirL merged 18 commits into
feat/fullstack-v12from
feat/experiment-preview

Conversation

@abbaseya

@abbaseya abbaseya commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Summary

Additive, SDK-only implementation of Experiment Preview (quick spec qs-02) for the Convert iOS SDK — one of six identical sibling specs (JS, PHP, Python, Ruby, Android, iOS). Mirrors the Android sibling (convertcom/android-sdk#52). Two capabilities:

(A) debugToken on ConvertConfiguration(debugToken: String? = nil) — every config fetch (initial + refresh scheduler) carries debug_token=<v> AND a forced _conv_low_cache=1; the on-disk config cache is neither read on cold start nor written while set; the token never reaches the track endpoint and is redacted from all log output.

(B) Per-context preview input context.setPreview(experienceId:variationId:) — force-decides one variation with FULL bypass (audiences/segments/locations/environment/status/traffic/stored-decisions/hash) and ZERO trace: nothing reaches the in-memory queue, the on-disk CoordinatedFileEventQueueStore, or the background URLSession, and no visitor-state/sticky writes occur for the entire preview. Fetches an absent experience via ?exp= + _conv_low_cache=1 (+ debug_token if set), memoized in memory per experienceId (60s TTL, never on disk). Pure helper PreviewParam.parse(_:) for the canonical link convert_preview={experienceId}.{variationId}. Inert on bad input (warning log, normal behavior). Per-context, no cross-context leak.

No API endpoint, no OpenAPI spec, no backend/serving change, no bucketing-contract change (preview bypasses bucketing entirely).

Stacked PR

Base is feat/fullstack-v12 (NOT main), matching the Android sibling. main is a strict ancestor of the base.

Acceptance criteria (AC1–AC10) — all covered by tests

  • AC1 debugToken transport (initial + scheduler) · AC2 cache elimination · AC3 token hygiene (redaction + track route)
  • AC4 forced decision via ?exp= fetch · AC5 full bypass (draft/paused/env/non-running/zero-traffic/sticky)
  • AC6 zero trace (in-memory queue + on-disk queue store + background session + sticky writes, across a background transition)
  • AC7 isolation (concurrent non-preview context normal) · AC8 memoization (one fetch/60s, disk untouched)
  • AC9 parse helper · AC10 regression lock (full suite green, no change when neither feature used)

Tasks (beads, all closed — 6 feature + 3 remediation)

  • IOS-1 debugToken config + transport + cache bypass + redaction
  • IOS-2 PreviewParam.parse
  • IOS-3 PreviewDecision.forcedVariation (structural full bypass)
  • IOS-4 ?exp= fetch + PreviewState 60s memo
  • IOS-5 setPreview + per-context wiring + isolation + inert
  • IOS-6 zero-trace hardening (enqueue source + visitor-state writes)
  • IOS-fix (decision audit) gate segment-setter persistence under preview
  • IOS-fix2 (decision audit) gate feature-evaluation path (runFeature/runFeatures) under preview
  • IOS-fix3 (code review) single isPreviewActive read in the feature methods (torn-gate race)

QA

  • Readiness gate: 9.0/10 (passed first assessment).
  • Decision audit: PASS after an independent analyst→auditor loop found & fixed two real §2 zero-trace leaks (segment setters; feature-evaluation path) that the initial implementation missed; a final exhaustive 19-site sweep confirmed no residual leak.
  • Code review: clean after round 2 (one torn-read finding fixed).
  • Tests: full suite 364/364 (swift-testing); repo-wide swiftlint --strict 0 violations. Zero-trace tests exercise the real EventQueue + CoordinatedFileEventQueueStore + spies, each with a non-preview AC7 companion.

Test plan

  • CI green on feat/fullstack-v12
  • Confirm the debug-token QA-config path against a serving origin that honors ?debug_token=/?exp=

🤖 Generated with Claude Code

abbaseya and others added 17 commits July 8, 2026 06:01
…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>

@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 the experiment preview feature (qs-02) for the iOS SDK. It introduces PreviewParam for parsing preview parameters, PreviewDecision for forcing variation decisions, and PreviewState for managing in-memory memoization of preview configurations with a 60-second TTL. It updates ConvertContext to support setting preview targets eagerly, bypassing normal bucketing, and enforcing zero-trace tracking/persistence when a preview is active. Additionally, ConfigFetchService is updated to handle preview-specific URL construction and cache bypasses, and ToLoggable is enhanced to redact debug tokens from logs. Feedback on the changes highlights a potential actor reentrancy issue in PreviewState.resolveConfig(experienceId:) that could lead to duplicate concurrent network requests for the same experience ID, suggesting a task-based deduplication approach.

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 Sources/ConvertSwiftSDK/PreviewState.swift
@abbaseya abbaseya self-assigned this Jul 8, 2026
@abbaseya
abbaseya requested a review from a team July 8, 2026 09:52

@JosephSamirL JosephSamirL left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review — experiment preview (qs-02)

Overall this is solid — cleaner than the Android sibling (#52), and it fixes both mediums I raised there: no in-flight tracking-suppression window (setPreview awaits resolution before setForcedVariation, and isPreviewActive == forcedVariation != nil, so nothing is suppressed during the ?exp= fetch or on bad input), and trackConversion is a full no-op under preview (it suppresses the .conversion bus fire too). Verified against the JS feat/experiment-preview reference and PR-head source, not just the diff. CI is green here (Build/Lint/Test + Parity Suite, with ThreadSanitizer) because ci.yml triggers on all branches — the coverage #52 lacked.

Verified clean

  • PreviewParam.parse — faithful port of JS parsePreviewParam (^\d+$ + single-dot split; the >1-dot, empty-segment, and whitespace cases all match).
  • PreviewDecision.forcedVariation — shape identical to BucketingManager's normal decision: both build the same 4-field Variation(id:key:experienceId:experienceKey:) with the same ?? "" degradation.
  • AC2 cache bypass — complete and structural: loadCachedConfig returns nil under debugToken, fetchLiveConfig's write is gated, and fetchExperienceConfig never touches fileStore (via the getAndDecode extraction).
  • URL params match JS (exp / debug_token / _conv_low_cache / environment), tested order-agnostically; per-context PreviewState isolation (AC7); IOS-fix3 single-read hoist closes the torn-gate race.

One behavior to align with JS (inline)

The internal .bucketing observer event still fires for sibling experiences under preview — ExperienceManager.selectVariation step 6 fires it unconditionally (only the network enqueue and the sticky write are gated). JS fires none here, and this PR's own trackConversion already suppresses its .conversion fire — so this should match: none, not fires. Small fix: thread a !previewActive gate onto the step-6 fire. Details inline.

Minor (inline)

  • debug_token redaction regex is unanchored (the Android sibling anchored the same one — over-redaction only, never a leak).
  • The rawExperiences key-filter degrades oddly if a previewed experience ever has an empty key (near-unreachable, but worth a guard).

Gemini's actor-reentrancy note is a reasonable won't-fix — benign idempotent extra ?exp= GET, and the actor serializes the memo so there's no data race.

Not approving yet so the .bucketing alignment can land — everything else looks ready.

Comment thread Sources/ConvertSwiftSDKCore/Experience/ExperienceManager.swift
Comment thread Sources/ConvertSwiftSDKCore/Logging/ToLoggable.swift Outdated
Comment thread Sources/ConvertSwiftSDK/ConvertContext.swift
…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>
@abbaseya

Copy link
Copy Markdown
Collaborator Author

@JosephSamirL thanks for the thorough review — all three points addressed in 0ca75fe, threads resolved:

  1. .bucketing observer event under preview (your blocker) — new emitBucketing gate threaded from all four ConvertContext entry points through ExperienceManager/FeatureManager to the single fire site, passed as !previewActive. Now matches the JS oracle (no SystemEvents.BUCKETING under preview, target or sibling, across experience + feature paths) and this PR's existing .conversion/.segments suppression.
  2. debug_token regex — anchored with a negative-lookbehind word boundary (x_debug_token no longer over-redacted; a real token in any position still fully stripped).
  3. empty resolved keyPreviewDecision.forcedVariation now returns nil (inert-on-bad-input) for a nil/empty key or id.

Gemini's reentrancy note left as won't-fix per your endorsement. Locally green with the changes: SwiftLint --strict (0), swift build, Parity (74 vectors), DocC coverage (393), and the full suite under ThreadSanitizer — 370 passed / 1 skipped / 0 failed, 0 TSAN warnings (was 368; +3 new AC6/regex/decision cases). Ready for another look.

@abbaseya
abbaseya requested a review from JosephSamirL July 15, 2026 20:16

@JosephSamirL JosephSamirL left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Re-review — all three prior findings resolved. Approving.

Re-verified every finding against actual code at the current head (0ca75fe), re-locating each by content (not by stale line numbers). JS oracle parity confirmed: context.ts gates every SystemEvents.BUCKETING emit behind if (!this._preview) and trackConversion early-returns under preview → no observer events under preview. iOS now matches.

1. MEDIUM — .bucketing observer fire for siblings under preview → Resolved

  • ExperienceManager.selectVariation now gates the fire: if emitBucketing { await eventBus.fire(.bucketing, …) } (ExperienceManager.swift:204-211), with the variation still returned unconditionally at :212 (coherent rendering preserved). selectVariations forwards it (:292).
  • Threaded emitBucketing: !previewActive from all four ConvertContext entry points — runExperience (:311), runExperiences (:414, previewActive = forced != nil), runFeature (:495), runFeatures (:548) — and through FeatureManager.evaluateFeature/evaluateAllFeatures down to the same selectVariation site (FeatureManager.swift:130, :199).
  • trackConversion still suppresses its .conversion fire via the early guard !(await previewState.isPreviewActive) else { return } at ConvertContext.swift:629, before the fire at :666.
  • Default emitBucketing: true on every signature → non-preview call sites unchanged (AC10). No over-suppression: the gate is !previewActive, so a non-preview run still fires.
  • Coherent rendering intact: forced target short-circuits/returns (runExperience :282-284; runExperiences excludes then re-appends the forced variation :392/:416-418); siblings are still selected — only the observer fire + network enqueue + sticky write are suppressed.
  • Tested (not just claimed): PreviewZeroTraceTests.swift:224 asserts zero .bucketing "for the target or the sibling"; :290 covers the bulk path; regression guard :316 asserts a non-preview run still fires (== 1). Feature path mirrors it: PreviewFeatureZeroTraceTests.swift:215/:243 (zero under preview) + :270 regression guard.

2. MINOR — debug_token redaction unanchored → Resolved

ToLoggable.swift:92-93 anchors the branch: (?<![A-Za-z0-9_])debug_token=[^&#\s]*. x_debug_token= no longer over-redacted; a real token still stripped in any position (including string-start — deliberately safer than Android's (?<=[?&])).

3. MINOR — PreviewDecision.forcedVariation nil key → ""Resolved

PreviewDecision.swift:46-49 now guards nil/empty key and nil/empty id, returning nil (inert-on-bad-input), so a degenerate target can't poison the runExperiences sibling filter or the runExperience short-circuit.

CI — green on head 0ca75fe

All 4 check runs pinned to the head SHA succeeded: Build, Lint & Test ✅ and Parity Suite (100%-pass independent job) ✅. No failing or absent required check.

No regressions spotted in the new commit. LGTM.

@JosephSamirL
JosephSamirL merged commit 4611bef into feat/fullstack-v12 Jul 15, 2026
5 of 6 checks passed
@JosephSamirL
JosephSamirL deleted the feat/experiment-preview branch July 15, 2026 22: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