Skip to content

Commit 8dfbfa0

Browse files
feat(audience-consent): add CanTrack and CanIdentify predicates on ConsentLevel (SDK-226)
Centralise the consent rules behind two extension predicates and adopt them at every internal gate site that previously inlined the rule. ConsentLevelExtensions stays internal: same convention as IdentityTypeExtensions, no external caller today, and a future PR can promote when one lands. Predicates: CanTrack returns level != None. Fail-open on out-of-range casts so an unrecognised value still tracks, matching the gate shape every other call site already uses. CanIdentify returns level == Full. Fail-closed on out-of-range casts. Identify and Alias must not leak PII under uncertain consent. Adoption: ImmutableAudience.cs (5 sites): Identify gate, Alias gate, the private static CanTrack body, Enqueue's drain-time recheck closure, FireGameLaunch early return. Identity.cs (1 site): GetOrCreate's null-on-no-consent guard. Test coverage in Tests/Runtime/ConsentLevelTests.cs pins each enum value against both predicates plus the out-of-range cast for both fail policies, so a future change that flips either default breaks the build loudly. SDK-143 originally scoped the full consent infrastructure (state machine, persistence, side effects, server sync, gates). The state machine, persistence, side effects, and server sync shipped under SDK-119, SDK-142, and SDK-147. SDK-226 was carved out for the remaining slice this commit delivers. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent d142092 commit 8dfbfa0

4 files changed

Lines changed: 70 additions & 11 deletions

File tree

src/Packages/Audience/Runtime/ConsentLevel.cs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,16 @@ namespace Immutable.Audience
55
// How much data the Audience SDK is allowed to collect.
66
public enum ConsentLevel
77
{
8-
// No tracking.
8+
// No tracking
99
None,
10-
// Anonymous tracking only.
10+
// Anonymous tracking only
1111
Anonymous,
12-
// Full tracking, including identity.
12+
// Full tracking
1313
Full
1414
}
1515

1616
internal static class ConsentLevelExtensions
1717
{
18-
// Throws on unknown casts rather than emitting null: a null value
19-
// would poison the backend consent log.
2018
internal static string ToLowercaseString(this ConsentLevel level) => level switch
2119
{
2220
ConsentLevel.None => "none",
@@ -25,5 +23,9 @@ internal static class ConsentLevelExtensions
2523
_ => throw new System.ArgumentOutOfRangeException(
2624
nameof(level), level, "Unhandled ConsentLevel"),
2725
};
26+
27+
internal static bool CanTrack(this ConsentLevel level) => level != ConsentLevel.None;
28+
29+
internal static bool CanIdentify(this ConsentLevel level) => level == ConsentLevel.Full;
2830
}
2931
}

src/Packages/Audience/Runtime/Core/Identity.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ internal static void ClearCache()
6363
internal static string? GetOrCreate(string persistentDataPath, ConsentLevel consent)
6464
{
6565
// No ID until the player grants at least anonymous consent.
66-
if (consent == ConsentLevel.None)
66+
if (!consent.CanTrack())
6767
return null;
6868

6969
// Fast path — already loaded this session, no lock needed.

src/Packages/Audience/Runtime/ImmutableAudience.cs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ public static void Identify(string userId, string identityType, Dictionary<strin
192192
Log.Warn("Identify called with null or empty userId — dropping.");
193193
return;
194194
}
195-
if (_consent != ConsentLevel.Full)
195+
if (!_consent.CanIdentify())
196196
{
197197
Log.Warn($"Identify discarded — requires Full consent, current is {_consent}");
198198
return;
@@ -227,7 +227,7 @@ public static void Alias(string fromId, string fromType, string toId, string toT
227227
Log.Warn("Alias called with null or empty fromId/toId — dropping.");
228228
return;
229229
}
230-
if (_consent != ConsentLevel.Full)
230+
if (!_consent.CanIdentify())
231231
{
232232
Log.Warn($"Alias discarded — requires Full consent, current is {_consent}");
233233
return;
@@ -556,7 +556,7 @@ internal static void ResetState()
556556

557557
private static bool CanTrack()
558558
{
559-
return _initialized && _consent != ConsentLevel.None;
559+
return _initialized && _consent.CanTrack();
560560
}
561561

562562
// Shallow-copy the caller's dict so a post-call mutation cannot race the drain-thread serialiser.
@@ -570,7 +570,7 @@ private static void Enqueue(Dictionary<string, object>? msg)
570570

571571
// Re-check consent inside the drain lock so a SetConsent(None) racing
572572
// the caller's CanTrack cannot leak this event past the purge.
573-
queue.EnqueueChecked(msg, () => _consent != ConsentLevel.None);
573+
queue.EnqueueChecked(msg, () => _consent.CanTrack());
574574
}
575575

576576
private static void SendBatch()
@@ -632,7 +632,7 @@ private static void RescheduleSendTimer(HttpTransport transport)
632632
// landing between Init returning and here still drops the event.
633633
private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAtInit)
634634
{
635-
if (consentAtInit == ConsentLevel.None) return;
635+
if (!consentAtInit.CanTrack()) return;
636636

637637
var properties = new Dictionary<string, object>();
638638

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
using System;
2+
using NUnit.Framework;
3+
4+
namespace Immutable.Audience.Tests
5+
{
6+
[TestFixture]
7+
internal class ConsentLevelTests
8+
{
9+
[TestCase(ConsentLevel.None, "none")]
10+
[TestCase(ConsentLevel.Anonymous, "anonymous")]
11+
[TestCase(ConsentLevel.Full, "full")]
12+
public void ToLowercaseString_MapsEachEnumValueToLowercaseBackendString(ConsentLevel level, string expected)
13+
{
14+
Assert.AreEqual(expected, level.ToLowercaseString());
15+
}
16+
17+
[Test]
18+
public void ToLowercaseString_UnknownValue_Throws()
19+
{
20+
var invalid = (ConsentLevel)999;
21+
22+
Assert.Throws<ArgumentOutOfRangeException>(() => invalid.ToLowercaseString());
23+
}
24+
25+
[TestCase(ConsentLevel.None, false)]
26+
[TestCase(ConsentLevel.Anonymous, true)]
27+
[TestCase(ConsentLevel.Full, true)]
28+
public void CanTrack_TrueForAnonymousAndFull(ConsentLevel level, bool expected)
29+
{
30+
Assert.AreEqual(expected, level.CanTrack());
31+
}
32+
33+
[Test]
34+
public void CanTrack_UnknownValue_ReturnsTrue()
35+
{
36+
var invalid = (ConsentLevel)999;
37+
38+
Assert.IsTrue(invalid.CanTrack());
39+
}
40+
41+
[TestCase(ConsentLevel.None, false)]
42+
[TestCase(ConsentLevel.Anonymous, false)]
43+
[TestCase(ConsentLevel.Full, true)]
44+
public void CanIdentify_TrueOnlyForFull(ConsentLevel level, bool expected)
45+
{
46+
Assert.AreEqual(expected, level.CanIdentify());
47+
}
48+
49+
[Test]
50+
public void CanIdentify_UnknownValue_ReturnsFalse()
51+
{
52+
var invalid = (ConsentLevel)999;
53+
54+
Assert.IsFalse(invalid.CanIdentify());
55+
}
56+
}
57+
}

0 commit comments

Comments
 (0)