Skip to content

[AI-FSSDK] [FSSDK-12813] normalize campaign_id, variation_id, and entity_id on decision events#642

Merged
jaeopt merged 4 commits into
masterfrom
ai/jaeopt/FSSDK-12813-holdout-event
Jun 24, 2026
Merged

[AI-FSSDK] [FSSDK-12813] normalize campaign_id, variation_id, and entity_id on decision events#642
jaeopt merged 4 commits into
masterfrom
ai/jaeopt/FSSDK-12813-holdout-event

Conversation

@jaeopt

@jaeopt jaeopt commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Normalizes the three ID fields on outgoing decision impression events so the event-processing pipeline always receives pipeline-valid values:

  • decisions[].campaign_id
  • decisions[].variation_id
  • events[].entity_id

Applies uniformly across decision types (experiment, feature test, rollout, holdout) — there is no per-type branching in the normalization path.

Per the updated spec (FSSDK-12813):

  • campaign_id and entity_id MUST be non-empty decimal-digit strings; when invalid (empty, whitespace, null, or non-numeric), the SDK falls back to the decision's experiment_id. Because both fields share the same source (experiment.layerId) and the same fallback, they MUST always hold the same normalized value on the wire (SC-006).
  • variation_id MUST be a non-empty decimal-digit string OR explicit JSON null; when invalid (empty, whitespace, non-numeric), the SDK emits null.
  • The fix lives in the event-builder layer only — no public API change, no datafile schema change, no new logs or warnings on the normalization path (FR-007).
  • Events are never dropped as a result of normalization (FR-006).
  • Conversion events derive entity_id from a different upstream source (the conversion event's own datafile id) and are out of scope (FR-010).

Why generalize beyond holdouts?

The bug's loudest manifestation today is on holdout impression events (holdouts may legitimately lack layerId, so the invalid-campaign_id / entity_id path fires on every holdout event). Audited the SDK fleet and confirmed valid campaign_id / entity_id values are always non-empty numeric strings for non-holdout decisions in well-formed datafiles — so applying the same normalization uniformly is a true no-op for those paths today (verified by sibling regression suites, see below). The uniform rule simplifies the code and provides forward-compatibility for any future decision type or future datafile edge case.

Changes

  • Sources/Implementation/Events/BatchEventBuilder.swift
    • createImpressionEvent routes campaign_id / variation_id through normalizeDecisionIds(...) for every decision (no isHoldout branch).
    • DispatchEvent.entityID now receives the normalized campaignId instead of rawCampaignId, so events[].entity_id and decisions[].campaign_id share the same fallback (FR-009 / SC-006).
    • Adds isValidNumericIdString(_:) helper restricted to ASCII [0-9].
  • Sources/Data Model/DispatchEvents/BatchEvent.swiftDecision.variationID is String? with a custom encode(to:) that emits explicit JSON null (not encodeIfPresent) when nil, so the on-the-wire JSON carries "variation_id": null for the no-variation case.
  • Tests/OptimizelyTests-Common/BatchEventBuilderTests_HoldoutIds.swift — 18 tests covering numeric-string validation, uniform campaign_id / variation_id normalization, FR-006 passthrough, JSON null serialization, and an end-to-end impression event assertion that campaign_id and entity_id both fall back to the holdout's id and are byte-equal. (Filename retained to keep project.pbxproj references stable; contents cover all decision types and all three ID fields.)
  • OptimizelySwiftSDK.xcodeproj/project.pbxproj — registers the test file with OptimizelyTests-Common-iOS and OptimizelyTests-Common-tvOS.

Verification

  • New tests: 18/18 pass (BatchEventBuilderTests_HoldoutIds)
  • Regression suites: 40/40 pass (BatchEventBuilderTests_Events, BatchEventBuilderTests_Attributes, BatchEventBuilderTests_EventTags, BatchEventBuilderTests_Region, DecisionServiceTests_Holdouts) — confirms the generalized normalization is a true no-op for existing valid non-holdout events (SC-003) and that existing impression-event assertions (entity_id == campaign_id) continue to hold.
  • Run: xcodebuild -workspace OptimizelySwiftSDK.xcworkspace -scheme OptimizelySwiftSDK-iOS -destination 'platform=iOS Simulator,name=iPhone 17' test

Jira Ticket

FSSDK-12813

jaeopt added 2 commits June 24, 2026 10:52
Apply the campaign_id / variation_id normalization uniformly across every
decision type (experiment, feature test, rollout, holdout) instead of
scoping it to holdouts. The contract is the same for every dispatched
event, so removing the isHoldout branch simplifies the code and provides
forward-compatibility for future decision types.

For well-formed datafiles this is a no-op for non-holdout decisions
(their layerId values are already valid numeric strings); the
normalization continues to fire on holdout events and acts as a safety
net for any future malformed input.

- Drop isHoldout parameter from normalizeDecisionIds
- Rename test names from holdout_* to apply uniformly
- 17 unit tests pass; 40 regression tests in sibling suites pass
@jaeopt jaeopt changed the title [AI-FSSDK] [FSSDK-12813] fix invalid ids in holdout events [AI-FSSDK] [FSSDK-12813] normalize campaign_id and variation_id on decision events Jun 24, 2026
…009)

events[].entity_id on impression events shares the same source
(experiment.layerId) and the same fallback contract as
decisions[].campaign_id, so it has the same bug for holdouts. Use the
normalized campaign_id value for entity_id too, guaranteeing the two
fields never diverge on the wire.

- BatchEventBuilder.swift: DispatchEvent constructor now passes
  campaignId (normalized) instead of rawCampaignId
- BatchEventBuilderTests_HoldoutIds.swift: add end-to-end test asserting
  both campaign_id and entity_id fall back to holdout.id when layerId
  is empty, and that the two fields are byte-equal (SC-006)
- 18/18 unit tests pass; 40/40 regression tests pass
@jaeopt jaeopt changed the title [AI-FSSDK] [FSSDK-12813] normalize campaign_id and variation_id on decision events [AI-FSSDK] [FSSDK-12813] normalize campaign_id, variation_id, and entity_id on decision events Jun 24, 2026
Mat001
Mat001 previously approved these changes Jun 24, 2026

@Mat001 Mat001 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

lgtm

Two impression tests still asserted the pre-normalization variation_id:
- testDecide_sendImpressionForNullVariation expected "" for a rollout
  with no variation; new code emits JSON null.
- Holdouts testDecide_sendImpression expected "id_holdout_variation",
  which is non-numeric and now normalizes to null.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@coveralls

Copy link
Copy Markdown

Coverage Status

Coverage is 93.849%ai/jaeopt/FSSDK-12813-holdout-event into master. No base build found for master.

@jaeopt
jaeopt merged commit 6b8a6f3 into master Jun 24, 2026
19 of 22 checks passed
@jaeopt
jaeopt deleted the ai/jaeopt/FSSDK-12813-holdout-event branch June 24, 2026 22:45
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.

3 participants