Skip to content

data management

Joseph Samir edited this page Jun 21, 2026 · 2 revisions

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?

The Problem: Where Does Everything Live?

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:

  1. Store Project Setup: Keep track of all the experiences, features, audiences, goals, etc., defined in your Convert account for your specific project.
  2. Fetch Updates: Get the latest version of this project setup from the Convert API or use data you provide directly.
  3. 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.
  4. Store Visitor Segments: Keep track of visitor properties or segments that might affect targeting.
  5. 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 Data Layer

The iOS SDK handles data management through two complementary components rather than a single DataManager class:

  • ConfigStore (Sources/ConvertSwiftSDKCore/Data/ConfigStore.swift) — a Swift actor that owns the current ProjectConfig snapshot and the one-shot readiness gate. It is the "bookshelf" holding the project's experiment/feature/goal definitions. ConfigFetchService loads and refreshes it; ExperienceManager reads from it on each runExperience call.
  • DecisionStore (Sources/ConvertSwiftSDKCore/) — a Swift actor that owns all per-visitor state: sticky bucketing decisions, goal dedup marks, and segments. It persists decisions to an Application Support file on disk via a CoordinatedFileStore adapter, 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.

The Bookshelf: ConfigStore and ProjectConfig

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? — wire account_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? — carries id: String? (the projectId) and utcOffset: 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
Loading

The Filing System: DecisionStore

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
Loading

How DataManager Responsibilities Map to iOS

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 ConfigFetchServiceConfigStore.setConfig
Remember visitor bucketing decisions DecisionStore actor
Persist decisions across sessions DecisionStoreCoordinatedFileStore (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

How It Works: Config Fetch and Storage

When ConvertSwiftSDK initialises with an SDK key, the detached Task runs this sequence:

  1. 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.
  2. Cold-start queue drain: await eventQueue.start() re-loads any events persisted to disk by a previous process before they could be flushed.
  3. Cache hit: await configFetchService.loadCachedConfig() reads the on-disk Application Support cache. If found, configStore.setConfig(cached) fires .ready immediately — the SDK is usable while the live fetch is in-flight.
  4. Live fetch: await configFetchService.fetchLiveConfig() performs a URLSession GET. On success, configStore.setConfig(live) fires .configUpdated (the ready gate was already latched by the cache hit, or fires .ready now on a cold start with no cache).

How It Works: Bucketing Orchestration

When ConvertContext.runExperience("headline-test") is called:

  1. Read snapshot: await sdk.configStore.getSnapshot()nil → return nil immediately (degraded).
  2. Overlay segments: Read the visitor's current Segments from DecisionStore and merge them onto the explicit createContext(attributes:) values, so an audience rule on country can match a setDefaultSegments write (AC11).
  3. Delegate to ExperienceManager.selectVariation(...): a. Looks up the full ConfigExperience by key in the snapshot via fullExperience(forKey:). b. Check cached decision: Reads the DecisionStore for the composite visitor key. If a decision exists and the experience has a permanent audience, 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: BucketingManager hashes "{experienceId}:{visitorId}" with MurmurHash3 (seed 9999) to choose a variation. e. Persist: Writes the new decision to DecisionStore (in-memory + disk). f. Enqueue tracking event (when tracking enabled): enqueues a bucketing entry into EventQueue. g. Returns the Variation.
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", …)
Loading

Audience Evaluation Cadence

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:

  1. Evaluation cadencewhen the rules are re-checked. Controlled by the audience's type field.
  2. Input sourcewhere 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 Three Audience Types (Authoring Model)

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

How the Three Types Surface in the Serving Config

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 ConfigSegment entry, whose rules are evaluated to decide segment membership.
  • An in_segment rule 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.

The Cadence Filter in ExperienceManager

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 (transient audiences, or permanent audiences 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.

The Input-Source Axis (Live vs Persisted)

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 DecisionStore or attributes passed to createContext). 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

Why the Axes Matter: The Common Misconception Trap

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 without utm_source=facebook in 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:

  1. Transient cadence is working correctly — ExperienceManager does not skip this audience at the second call. The rule is re-evaluated.
  2. But the rule's input is persisted. source_name reads visitor.source, which is backed by the _conv_r REFERRAL cookie. That cookie remembers "this visitor came from Facebook" for up to 6 months by design.
  3. 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.

Design Guarantee (Non-Goal)

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.

Conclusion

The iOS SDK splits data management across two actor-isolated components:

  1. ConfigStore — the bookshelf: holds the ProjectConfig snapshot, drives the readiness gate, and fires configUpdated on refreshes.
  2. DecisionStore — the filing system: stores per-visitor bucketing decisions, goal dedup marks, and segments, persisted to disk across app launches via CoordinatedFileStore.

You've learned:

  1. Why a central data management split is necessary.
  2. That ConfigStore holds project-wide configuration and DecisionStore holds visitor-specific state.
  3. How DecisionStore persists to Application Support so sticky decisions survive app restart.
  4. That bucketing orchestration is coordinated by ExperienceManager with input from RuleManager and BucketingManager.
  5. That you typically interact with both actors indirectly through ConvertContext.

Let's dive into the hashing and allocation logic next: BucketingManager!

Clone this wiki locally