Skip to content

Commit 91abb36

Browse files
committed
[FSSDK-12813] Relax campaign_id/entity_id validation to non-empty string per updated spec
1 parent 3f6b0ac commit 91abb36

4 files changed

Lines changed: 127 additions & 38 deletions

File tree

pkg/event/event_id_normalizer.go

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,28 @@ package event
2424
// test, rollout, holdout) produces uniform, valid wire output regardless of
2525
// what upstream code supplies.
2626
//
27-
// Numeric string definition: a non-empty string consisting entirely of decimal
28-
// digits [0-9]. Leading zeros are allowed. Whitespace, signs, decimals, and
29-
// exponents are NOT numeric strings.
30-
//
31-
// Normalization rules:
32-
// - campaign_id: must be a numeric string. If empty / non-numeric, substitute
33-
// experiment_id (which already comes from datafile and is expected numeric).
34-
// - variation_id: must be a numeric string OR JSON null. If empty /
35-
// non-numeric, substitute nil so it serializes as JSON null.
27+
// Contract (per relaxed FSSDK-12813 spec):
28+
// - campaign_id / entity_id: must be a non-empty string. Any character
29+
// content is allowed (IDs may be opaque, e.g. "default-12345", "layer_abc").
30+
// When the value is empty (""), fall back to experiment_id.
31+
// - variation_id: STRICT numeric-string-only. Must be a non-empty string
32+
// consisting entirely of decimal digits [0-9]. Anything else (empty,
33+
// whitespace, non-numeric) becomes nil so it serializes as JSON null.
3634
// - entity_id (impression events only): same rule as campaign_id, and must
3735
// equal the normalized campaign_id byte-for-byte for a given impression.
3836
//
37+
// Numeric string definition (for variation_id only): a non-empty string
38+
// consisting entirely of decimal digits [0-9]. Leading zeros are allowed.
39+
// Whitespace, signs, decimals, and exponents are NOT numeric strings.
40+
//
3941
// This normalization MUST NOT log, warn, drop, defer, or fail event dispatch.
4042

4143
// IsNumericIDString reports whether value is a non-empty string consisting
4244
// entirely of decimal digits [0-9]. Leading zeros are permitted. Whitespace,
4345
// signs (+/-), decimals, and exponents are rejected.
46+
//
47+
// This predicate is used for the strict variation_id contract only. The
48+
// campaign_id / entity_id contract has been relaxed to IsNonEmptyString.
4449
func IsNumericIDString(value string) bool {
4550
if value == "" {
4651
return false
@@ -54,25 +59,34 @@ func IsNumericIDString(value string) bool {
5459
return true
5560
}
5661

57-
// NormalizeCampaignID returns campaignID if it is a numeric string; otherwise
58-
// it returns experimentID. The caller is responsible for ensuring experimentID
59-
// is itself a valid numeric ID (it comes from the datafile and is expected to
60-
// be numeric). If experimentID is also non-numeric, it is returned as-is so
61-
// downstream behavior is unchanged for malformed datafiles.
62+
// IsNonEmptyString reports whether value has at least one character. It is
63+
// the predicate used for the relaxed campaign_id / entity_id contract: any
64+
// character content is allowed (IDs may be opaque, e.g. "default-12345",
65+
// "layer_abc"). Only the empty string triggers fallback to experiment_id.
66+
func IsNonEmptyString(value string) bool {
67+
return value != ""
68+
}
69+
70+
// NormalizeCampaignID returns campaignID if it is a non-empty string;
71+
// otherwise it returns experimentID. Per the relaxed FSSDK-12813 spec, any
72+
// non-empty character content is accepted for campaign_id / entity_id —
73+
// IDs may be opaque (e.g. "default-12345", "layer_abc"). Fallback to
74+
// experiment_id fires ONLY when campaignID is the empty string.
6275
//
6376
// This function is used for both decisions[].campaign_id and (for impression
6477
// events) events[].entity_id so both fields are normalized identically and
6578
// remain byte-equivalent.
6679
func NormalizeCampaignID(campaignID, experimentID string) string {
67-
if IsNumericIDString(campaignID) {
80+
if IsNonEmptyString(campaignID) {
6881
return campaignID
6982
}
7083
return experimentID
7184
}
7285

7386
// NormalizeVariationID returns a pointer to variationID if it is a numeric
7487
// string; otherwise it returns nil. A nil return value causes the field to
75-
// serialize as JSON null when marshaled via the wire format.
88+
// serialize as JSON null when marshaled via the wire format. The strict
89+
// numeric-string contract is intentional — variation_id remains numeric-only.
7690
func NormalizeVariationID(variationID string) *string {
7791
if IsNumericIDString(variationID) {
7892
v := variationID

pkg/event/event_id_normalizer_test.go

Lines changed: 86 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,14 @@ import (
3030

3131
// FSSDK-12813: Decision-event ID normalization tests.
3232
//
33-
// These tests verify the cross-SDK contract for outgoing decision events:
34-
// - campaign_id is a non-empty numeric string (substitute experiment_id otherwise)
35-
// - variation_id is a non-empty numeric string OR JSON null
36-
// - entity_id (impression events) mirrors campaign_id byte-for-byte
37-
// - rules apply uniformly to every decision type
38-
// - no logging, no warning, no dropping of events on the normalization path
33+
// These tests verify the cross-SDK contract for outgoing decision events
34+
// (per the relaxed spec):
35+
// - campaign_id / entity_id: non-empty string (any character content;
36+
// opaque IDs allowed). Fallback to experiment_id ONLY when empty.
37+
// - variation_id: STRICT non-empty numeric string OR JSON null.
38+
// - entity_id (impression events) mirrors campaign_id byte-for-byte.
39+
// - rules apply uniformly to every decision type.
40+
// - no logging, no warning, no dropping of events on the normalization path.
3941

4042
// --- IsNumericIDString -------------------------------------------------------
4143

@@ -77,6 +79,36 @@ func TestIsNumericIDString(t *testing.T) {
7779
}
7880
}
7981

82+
// --- IsNonEmptyString --------------------------------------------------------
83+
84+
func TestIsNonEmptyString(t *testing.T) {
85+
// Relaxed campaign_id / entity_id predicate. Only the empty string is
86+
// rejected — any other character content is accepted, including
87+
// whitespace-only, opaque IDs, and non-numeric content.
88+
cases := []struct {
89+
name string
90+
value string
91+
want bool
92+
}{
93+
{"plain digits", "12345", true},
94+
{"opaque layer id", "layer_abc", true},
95+
{"opaque default id", "default-12345", true},
96+
{"alpha placeholder", "holdout_123", true},
97+
{"single character", "a", true},
98+
{"whitespace only", " ", true},
99+
{"leading zero numeric", "0123456789", true},
100+
{"unicode", "१२३", true},
101+
102+
{"empty string is invalid", "", false},
103+
}
104+
105+
for _, tc := range cases {
106+
t.Run(tc.name, func(t *testing.T) {
107+
assert.Equal(t, tc.want, IsNonEmptyString(tc.value))
108+
})
109+
}
110+
}
111+
80112
// --- NormalizeCampaignID -----------------------------------------------------
81113

82114
func TestNormalizeCampaignID_ReturnsCampaignIDWhenNumeric(t *testing.T) {
@@ -85,30 +117,42 @@ func TestNormalizeCampaignID_ReturnsCampaignIDWhenNumeric(t *testing.T) {
85117
}
86118

87119
func TestNormalizeCampaignID_SubstitutesExperimentIDWhenCampaignEmpty(t *testing.T) {
88-
// FR-001/FR-002: empty campaign_id is replaced with experiment_id.
120+
// FR-001/FR-002 (relaxed): empty campaign_id is replaced with experiment_id.
121+
// Fallback fires ONLY for the empty string.
89122
got := NormalizeCampaignID("", "15402980349")
90123
assert.Equal(t, "15402980349", got)
91124
}
92125

93-
func TestNormalizeCampaignID_SubstitutesExperimentIDWhenCampaignNonNumeric(t *testing.T) {
126+
func TestNormalizeCampaignID_PassesThroughOpaqueCampaignID(t *testing.T) {
127+
// Relaxed spec: opaque, non-numeric campaign IDs pass through unchanged.
128+
// IDs may be of the form "layer_abc", "default-12345", etc.
94129
got := NormalizeCampaignID("layer_abc", "15402980349")
95-
assert.Equal(t, "15402980349", got)
130+
assert.Equal(t, "layer_abc", got)
96131
}
97132

98-
func TestNormalizeCampaignID_SubstitutesExperimentIDForWhitespaceCampaign(t *testing.T) {
133+
func TestNormalizeCampaignID_PassesThroughHoldoutPlaceholder(t *testing.T) {
134+
// Relaxed spec: alphanumeric placeholders like "holdout_123" are valid
135+
// campaign IDs and pass through; experiment_id substitution does NOT fire.
136+
got := NormalizeCampaignID("holdout_123", "15402980349")
137+
assert.Equal(t, "holdout_123", got)
138+
}
139+
140+
func TestNormalizeCampaignID_PassesThroughWhitespaceCampaign(t *testing.T) {
141+
// Relaxed spec: whitespace-only is non-empty, so it passes through.
142+
// This is intentionally permissive — we no longer try to "fix" content.
99143
got := NormalizeCampaignID(" ", "15402980349")
100-
assert.Equal(t, "15402980349", got)
144+
assert.Equal(t, " ", got)
101145
}
102146

103147
func TestNormalizeCampaignID_AllowsLeadingZeros(t *testing.T) {
104148
got := NormalizeCampaignID("0123", "9999")
105149
assert.Equal(t, "0123", got)
106150
}
107151

108-
func TestNormalizeCampaignID_PreservesMalformedExperimentIDFallback(t *testing.T) {
109-
// If both inputs are non-numeric, return experimentID as-is so we don't
110-
// silently invent data. Downstream behavior for malformed datafiles is
111-
// unchanged.
152+
func TestNormalizeCampaignID_PreservesExperimentIDFallback(t *testing.T) {
153+
// When campaign_id is empty, fall back to experiment_id verbatim — even
154+
// when experiment_id itself is non-numeric. Downstream behavior for
155+
// malformed datafiles is unchanged.
112156
got := NormalizeCampaignID("", "exp_42")
113157
assert.Equal(t, "exp_42", got)
114158
}
@@ -253,6 +297,33 @@ func TestImpressionEvent_NormalizesCampaignAndEntityIDsForHoldout(t *testing.T)
253297
"decisions[].campaign_id and events[].entity_id must be byte-equivalent")
254298
}
255299

300+
// TestImpressionEvent_PassesThroughOpaqueLayerID verifies the relaxed spec:
301+
// a non-numeric but non-empty LayerID (e.g. "layer_abc", "default-12345")
302+
// passes through to campaign_id and entity_id unchanged. Fallback to
303+
// experiment_id fires ONLY when LayerID is the empty string.
304+
func TestImpressionEvent_PassesThroughOpaqueLayerID(t *testing.T) {
305+
tc := testNormConfig{}
306+
exp := entities.Experiment{Key: "exp_key", LayerID: "default-12345", ID: "15402980349"}
307+
variation := numericVariation()
308+
309+
userEvent, ok := CreateImpressionUserEvent(tc, exp, &variation, newNormUserContext(),
310+
"flag_key", exp.Key, decisionPkg.FeatureTest, true, nil)
311+
assert.True(t, ok)
312+
313+
// Opaque LayerID passes through under the relaxed spec — NOT substituted
314+
// with experiment_id.
315+
assert.Equal(t, "default-12345", userEvent.Impression.CampaignID,
316+
"opaque LayerID must pass through (FSSDK-12813 relaxed)")
317+
assert.Equal(t, "default-12345", userEvent.Impression.EntityID,
318+
"entity_id must mirror campaign_id (FR-009)")
319+
320+
visitor := createVisitorFromUserEvent(userEvent)
321+
assert.Equal(t, "default-12345", visitor.Snapshots[0].Decisions[0].CampaignID)
322+
assert.Equal(t, "default-12345", visitor.Snapshots[0].Events[0].EntityID)
323+
assert.Equal(t, visitor.Snapshots[0].Decisions[0].CampaignID,
324+
visitor.Snapshots[0].Events[0].EntityID)
325+
}
326+
256327
// TestImpressionEvent_PassesThroughNumericCampaignID verifies happy path for
257328
// non-holdout decisions where LayerID is already a numeric ID.
258329
func TestImpressionEvent_PassesThroughNumericCampaignID(t *testing.T) {

pkg/event/factory.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -105,12 +105,14 @@ func createImpressionEvent(
105105

106106
// FSSDK-12813: Normalize campaign_id, variation_id, and entity_id uniformly
107107
// for every decision type (experiment, feature test, rollout, holdout).
108-
// - campaign_id falls back to experiment.ID when LayerID is empty / non-numeric
109-
// (e.g. holdouts have no layer).
108+
// - campaign_id falls back to experiment.ID when LayerID is the empty
109+
// string (e.g. holdouts have no layer). Any non-empty value — numeric
110+
// or opaque (e.g. "layer_abc") — passes through per the relaxed spec.
110111
// - entity_id mirrors the normalized campaign_id so both fields are
111112
// byte-equivalent on the wire (FR-009).
112113
// - variation_id is normalized later when the Decision is built, since the
113114
// Decision struct uses *string to allow JSON null output (FR-003/FR-004).
115+
// variation_id retains the STRICT numeric-string contract.
114116
normalizedCampaignID := NormalizeCampaignID(experiment.LayerID, experiment.ID)
115117

116118
event := ImpressionEvent{

pkg/event/factory_test.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -339,11 +339,13 @@ func TestCreateImpressionUserEventWithCmabUUID(t *testing.T) {
339339
func TestCreateImpressionUserEventForHoldout(t *testing.T) {
340340
tc := TestConfig{}
341341

342-
// FSSDK-12813: Decision-event ID fields (campaign_id, variation_id,
343-
// experiment_id, entity_id) must be numeric strings on the wire. Use
344-
// numeric IDs in fixtures so the assertions below exercise the
345-
// happy-path normalization (campaign_id falls back to experiment_id
346-
// when LayerID is empty).
342+
// FSSDK-12813: variation_id retains a strict numeric-string contract on
343+
// the wire (non-numeric values serialize as JSON null). campaign_id /
344+
// entity_id are relaxed to non-empty strings (opaque IDs allowed; the
345+
// only fallback to experiment_id is when LayerID is the empty string).
346+
// Use numeric IDs in fixtures so the happy-path assertions below exercise
347+
// both the variation_id pass-through and the empty-LayerID fallback for
348+
// campaign_id without entanglement.
347349
testHoldout := entities.Experiment{
348350
Key: "test_holdout",
349351
LayerID: "",

0 commit comments

Comments
 (0)