-
Notifications
You must be signed in to change notification settings - Fork 0
data management
Welcome back! In the previous chapters, we explored the Context object for individual visitors, the ExperienceManager for A/B tests, and the FeatureManager for feature flags. You might have noticed a pattern: when we asked the SDK to make a decision (like "which variation should user123 see?" or "is dark-mode enabled?"), the managers didn't just guess. They needed information!
But where does all this information live? How does the SDK know about your specific experiments, features, targeting rules, and which variation 'user123' was assigned to last time?
Imagine you're building a complex application. You have user settings, product information, application configuration, and maybe temporary data about the user's current session. Where do you keep all this data so different parts of your application can access it when needed? You probably use a database or some central configuration store.
The Convert SDK faces a similar challenge. It needs a central place to:
- Store Project Setup: Keep track of all the experiences, features, audiences, goals, etc., defined in your Convert account for your specific project.
- Fetch Updates: Get the latest version of this project setup from the Convert API or use data you provide directly.
- Remember Visitor Decisions: If 'user123' is assigned to 'variation-B' of the headline test, the SDK needs to remember this so they see the same headline consistently.
- Store Visitor Segments: Keep track of visitor properties or segments that might affect targeting.
- Provide Data Access: Allow other managers (like ExperienceManager and FeatureManager) to easily retrieve the specific information they need.
How does the SDK manage this central pool of information?
The iOS SDK handles data management through two complementary components rather than a single DataManager class:
-
ConfigStore(Sources/ConvertSwiftSDKCore/Data/ConfigStore.swift) — a Swiftactorthat owns the currentProjectConfigsnapshot and the one-shot readiness gate. It is the "bookshelf" holding the project's experiment/feature/goal definitions.ConfigFetchServiceloads and refreshes it;ExperienceManagerreads from it on eachrunExperiencecall. -
DecisionStore(Sources/ConvertSwiftSDKCore/) — a Swiftactorthat owns all per-visitor state: sticky bucketing decisions, goal dedup marks, and segments. It persists decisions to an Application Support file on disk via aCoordinatedFileStoreadapter, enabling cross-session sticky assignments without a caller-provided data store.
Both actors are constructed once inside ConvertSwiftSDK.init and shared across every ConvertContext created from that SDK handle, so decisions and segments converge on a single source of truth.
When the SDK initialises with an sdkKey, a detached Task fetches the config via ConfigFetchService and calls configStore.setConfig(projectConfig). The ProjectConfig value holds the decoded JSON for the project:
-
accountId: String?— wireaccount_id -
experiences: [Experience]?— stripped experience entries (id + type) -
rawExperiences: [ConfigExperience]?— full generated experiences with key, variations, audiences, locations -
audiences: [ConfigAudience]?,segments: [ConfigSegment]?,locations: [ConfigLocation]?,features: [ConfigFeature]? -
goals: [ConfigGoalOrSentinel]?— goals decoded tolerantly (each element is a sentinel-wrapped value to survive wire drift D3) -
project: Project?— carriesid: String?(the projectId) andutcOffset: Int?
ProjectConfig uses a field-by-field degrading Codable decoder (no .convertFromSnakeCase strategy — AR13 forbids it). Four known wire drifts (D1–D4) are isolated to nil or .sentinel arms rather than aborting the whole config.
ConvertContext.runExperience reads the snapshot on every call: await sdk.configStore.getSnapshot(). A nil snapshot (pre-ready or degraded) short-circuits immediately to nil (AOD-6 — never throws).
graph LR
CFS["ConfigFetchService\n(URLSession + File cache)"]
CS["ConfigStore (actor)\nProjectConfig? snapshot\nReady gate"]
EX["ExperienceManager\nreads snapshot"]
FM["FeatureManager\nreads snapshot"]
Ctx["ConvertContext\nreads snapshot\nfor each decision call"]
CFS -- setConfig(config) --> CS
CS -- getSnapshot() --> Ctx
Ctx --> EX
Ctx --> FM
The DecisionStore actor holds per-visitor state in a [String: StoreData] in-memory map, where the key is the composite "{accountId}-{projectId}-{visitorId}" string.
StoreData carries:
-
Bucketing decisions — a
[String: String]map of experience-id → variation-id (sticky assignment) -
Goal dedup marks — a
Set<String>of goalIds already converted (prevents double-counting) -
Segments — the visitor's current
Segments(country, browser, devices, source, campaign, visitorType, customSegments)
On writes, DecisionStore persists the entire map to an Application Support file via CoordinatedFileStore — a file-coordination-backed adapter. On cold start, ConvertSwiftSDK.init's Task calls await decisionStore.loadFromDisk() before config loading, so sticky decisions from previous sessions are available the moment the first runExperience call arrives.
graph LR
Ctx[ConvertContext]
DS["DecisionStore (actor)\n[visitorKey: StoreData]"]
File["Application Support file\n(CoordinatedFileStore)"]
subgraph DecisionStore Scope
DS -- Reads/Writes in-memory map --> DS
DS -- Persists (async) --> File
end
Ctx -- read/write --> DS
The cross-SDK DataManager concept is split as follows in the iOS SDK:
| Cross-SDK DataManager responsibility | iOS component |
|---|---|
| Store project configuration (experiences, features, goals, audiences) |
ConfigStore actor → ProjectConfig value |
| Fetch/refresh configuration |
ConfigFetchService → ConfigStore.setConfig
|
| Remember visitor bucketing decisions |
DecisionStore actor |
| Persist decisions across sessions |
DecisionStore → CoordinatedFileStore (Application Support) |
Provide entity lookups (getEntity, getEntityById) |
ProjectConfig methods: fullExperience(forKey:), audience(id:), location(id:), goal(forKey:)
|
| Orchestrate bucketing (check cache → rules → bucket → store) |
ExperienceManager.selectVariation(...) + DecisionStore + RuleManager + BucketingManager
|
When ConvertSwiftSDK initialises with an SDK key, the detached Task runs this sequence:
-
Hydrate persisted decisions:
await decisionStore.loadFromDisk()restores the on-disk store to the in-memory map. This runs BEFORE config loading so sticky decisions are available immediately. -
Cold-start queue drain:
await eventQueue.start()re-loads any events persisted to disk by a previous process before they could be flushed. -
Cache hit:
await configFetchService.loadCachedConfig()reads the on-disk Application Support cache. If found,configStore.setConfig(cached)fires.readyimmediately — the SDK is usable while the live fetch is in-flight. -
Live fetch:
await configFetchService.fetchLiveConfig()performs aURLSessionGET. On success,configStore.setConfig(live)fires.configUpdated(the ready gate was already latched by the cache hit, or fires.readynow on a cold start with no cache).
When ConvertContext.runExperience("headline-test") is called:
-
Read snapshot:
await sdk.configStore.getSnapshot()—nil→ returnnilimmediately (degraded). -
Overlay segments: Read the visitor's current
SegmentsfromDecisionStoreand merge them onto the explicitcreateContext(attributes:)values, so an audience rule oncountrycan match asetDefaultSegmentswrite (AC11). -
Delegate to
ExperienceManager.selectVariation(...): a. Looks up the fullConfigExperienceby key in the snapshot viafullExperience(forKey:). b. Check cached decision: Reads theDecisionStorefor the composite visitor key. If a decision exists and the experience has apermanentaudience, returns the cached variation immediately. c. Evaluate rules:RuleManager.evaluate(rules:against:)checks the audience and location rule sets against the merged attribute map. d. Bucket:BucketingManagerhashes"{experienceId}:{visitorId}"with MurmurHash3 (seed 9999) to choose a variation. e. Persist: Writes the new decision toDecisionStore(in-memory + disk). f. Enqueue tracking event (when tracking enabled): enqueues a bucketing entry intoEventQueue. g. Returns theVariation.
sequenceDiagram
participant Ctx as ConvertContext
participant CS as ConfigStore (actor)
participant DS as DecisionStore (actor)
participant EM as ExperienceManager
participant RM as RuleManager
participant BM as BucketingManager
participant EQ as EventQueue (actor)
Ctx->>+CS: getSnapshot()
CS-->>-Ctx: ProjectConfig
Ctx->>+DS: currentSegments(forVisitorKey:)
DS-->>-Ctx: Segments
Ctx->>+EM: selectVariation(forKey: "headline-test", in: config, ...)
EM->>+DS: load(forVisitorKey:)
DS-->>-EM: existing decision? (nil)
EM->>EM: fullExperience(forKey: "headline-test") → ConfigExperience
EM->>+RM: evaluate(audienceRules, against: attributes)
RM-->>-EM: true (matches)
EM->>+BM: bucket(visitorId, experienceId, variations)
BM-->>-EM: variationId = "variation-B-id"
EM->>+DS: save(decision: variationId, forVisitorKey:)
DS-->>-EM: (persisted to disk)
EM->>+EQ: enqueue(.bucketing(…), for: visitorId)
EQ-->>-EM: (buffered)
EM-->>-Ctx: Variation(key: "variation-b", …)
So far we have treated "the visitor's audience rules pass" as a single yes/no decision. In reality there are two independent axes that together determine whether a visitor is in an audience at any given moment:
-
Evaluation cadence — when the rules are re-checked. Controlled by the audience's
typefield. - Input source — where the data behind each rule comes from (live page state vs cached visitor state). Controlled per rule type.
Conflating the two is the single most common source of audience-behavior surprises. The rest of this section unpacks each axis and how they apply in the iOS SDK.
The backend audience schema (AudienceTypeEnum, and the Convert Management API docs) defines three audience types:
| Type | URL rules allowed | When rules are evaluated | What happens once matched |
|---|---|---|---|
permanent |
No | Only at the first bucketing check for this experience | Visitor stays matched for the lifetime of the experience, even if underlying conditions change |
transient |
No | On every bucketing check (every call, every re-check) | Visitor can un-match on the next check — membership is recomputed each time |
segmentation |
Yes | On every check, until the first match | Visitor is tagged into the corresponding segment; segment membership then persists across sessions (subject to storage — see below) |
segmentation is the only type that allows URL-based rules in its condition set, because the first URL match is what locks the visitor into the segment. The V2 API enforces this via two discriminated sub-schemas (AudienceWithUrlMatching for segmentation, AudienceWithoutUrlMatching for the other two).
The SDK does not see all three types as audiences — the serving config surfaces only two, permanent and transient.
That is because segmentation audiences are resolved server-side into a separate entity — ConfigSegment (see Data Model → "ConfigSegment"). A single authored segmentation audience becomes:
- A
ConfigSegmententry, whose rules are evaluated to decide segment membership. - An
in_segmentrule inside any audience or experience that previously referenced it, so the SDK still has a way to ask "is this visitor tagged into segment X?".
Segment membership persistence in Fullstack projects depends entirely on the DecisionStore — without the iOS SDK's built-in on-disk persistence (via CoordinatedFileStore), segments are recomputed from scratch on each app launch. With it, segments persist across launches as part of the StoreData on disk.
When selectVariation loads the visitor's existing decision and the audience list, it filters:
-
Visitor already bucketed into this experience AND audience is
permanent→ skip that audience. Its first-match decision stands. -
Everything else (
transientaudiences, orpermanentaudiences on a not-yet-bucketed visitor) → evaluate its rules again now.
Permanent audiences are therefore "decided once, frozen forever." Transient audiences are "decided every time." Nothing else in the engine distinguishes them.
Independently from cadence, each rule type reads its comparison value from one of two places:
- Live — read fresh from the current request / state at the exact moment the rule is evaluated. Changes between calls.
-
Persisted — read from cached visitor state (the
DecisionStoreor attributes passed tocreateContext). Carries its own semantics and may span sessions.
The headline groupings:
| Input source | Example rule types |
|---|---|
| Live |
url, url_with_query, query_string, query_param, fragment, hostname, protocol, element_visible, element_contains_text, cookie, screen_size, time rules (day_of_week, hour_of_day, …), js_condition
|
| Persisted |
source_name, medium, keyword, campaign (REFERRAL cookie, 6-month TTL), UTM vars, country/region/city/continent/zip_code (cached on visitor object), visitor_type, visits_count, pages_count, sessions_count, visitor_goals_count, in_experience, in_variation, in_segment, browser, os, browser_language, custom_variable, page_tag
|
A concrete support case that illustrates this clearly:
Customer sets up a Transient audience with a single rule:
source_name contains "facebook". They expect the audience to drop the visitor the moment they navigate to a page withoututm_source=facebookin the URL.
What the customer sees: the visitor stays matched even after navigating away. It looks like Transient was ignored, or like it's caching audience membership.
What is actually happening:
- Transient cadence is working correctly —
ExperienceManagerdoes not skip this audience at the second call. The rule is re-evaluated. - But the rule's input is persisted.
source_namereadsvisitor.source, which is backed by the_conv_rREFERRAL cookie. That cookie remembers "this visitor came from Facebook" for up to 6 months by design. - So the rule evaluates against
"facebook"on every call — and matches every time — regardless of the current URL.
This is not a bug; it is the two axes meeting at a point where the customer's mental model expected them to be the same.
The separation of cadence from input source is intentional and stable. In particular:
- The engine will not silently change the input source of a rule based on the audience type that contains it. A rule that reads persisted state inside a Permanent audience reads the same persisted state inside a Transient audience.
-
Any change here is a data-integrity change, not a UX change. Every currently-running Transient audience using
source_name,medium,campaign,keyword,in_experience,visitor_type, geo rules, etc. would silently re-bucket its visitors, retroactively polluting in-flight experiment reports. This platform has served this behavior consistently for ~10 years; changes of this kind require product-level review, not patching.
The iOS SDK splits data management across two actor-isolated components:
-
ConfigStore— the bookshelf: holds theProjectConfigsnapshot, drives the readiness gate, and firesconfigUpdatedon refreshes. -
DecisionStore— the filing system: stores per-visitor bucketing decisions, goal dedup marks, and segments, persisted to disk across app launches viaCoordinatedFileStore.
You've learned:
- Why a central data management split is necessary.
- That
ConfigStoreholds project-wide configuration andDecisionStoreholds visitor-specific state. - How
DecisionStorepersists to Application Support so sticky decisions survive app restart. - That bucketing orchestration is coordinated by
ExperienceManagerwith input fromRuleManagerandBucketingManager. - That you typically interact with both actors indirectly through
ConvertContext.
Let's dive into the hashing and allocation logic next: BucketingManager!
Copyrights © 2026 All Rights Reserved by Convert Insights, Inc.
Getting Started
iOS SDK
- Quickstart
- Installation
- Initialization
- Configuration
- Return Types & Models
- Code Examples
- Offline Behavior
- Tracking Control
- App Privacy & Data Collection
- Objective-C Interop
Core Concepts
- Experiences & Variations
- Feature Flags
- Bucketing Algorithm
- Rule Evaluation
- Segments
- Data Management
- Event System
- API Communication
How-To Guides
- Running Experiences
- Running Features
- Tracking Conversions
- Visitor Context
- Persistent Storage
- Troubleshooting
Contributing