From 8dfbfa0fa14c68d0508da0084dcff51e919636b0 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Thu, 23 Apr 2026 07:24:48 +1000 Subject: [PATCH] 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) --- src/Packages/Audience/Runtime/ConsentLevel.cs | 12 ++-- .../Audience/Runtime/Core/Identity.cs | 2 +- .../Audience/Runtime/ImmutableAudience.cs | 10 ++-- .../Tests/Runtime/ConsentLevelTests.cs | 57 +++++++++++++++++++ 4 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 src/Packages/Audience/Tests/Runtime/ConsentLevelTests.cs diff --git a/src/Packages/Audience/Runtime/ConsentLevel.cs b/src/Packages/Audience/Runtime/ConsentLevel.cs index deb0b189e..db650a344 100644 --- a/src/Packages/Audience/Runtime/ConsentLevel.cs +++ b/src/Packages/Audience/Runtime/ConsentLevel.cs @@ -5,18 +5,16 @@ namespace Immutable.Audience // How much data the Audience SDK is allowed to collect. public enum ConsentLevel { - // No tracking. + // No tracking None, - // Anonymous tracking only. + // Anonymous tracking only Anonymous, - // Full tracking, including identity. + // Full tracking Full } internal static class ConsentLevelExtensions { - // Throws on unknown casts rather than emitting null: a null value - // would poison the backend consent log. internal static string ToLowercaseString(this ConsentLevel level) => level switch { ConsentLevel.None => "none", @@ -25,5 +23,9 @@ internal static class ConsentLevelExtensions _ => throw new System.ArgumentOutOfRangeException( nameof(level), level, "Unhandled ConsentLevel"), }; + + internal static bool CanTrack(this ConsentLevel level) => level != ConsentLevel.None; + + internal static bool CanIdentify(this ConsentLevel level) => level == ConsentLevel.Full; } } diff --git a/src/Packages/Audience/Runtime/Core/Identity.cs b/src/Packages/Audience/Runtime/Core/Identity.cs index 8ae1f6778..2df440eb1 100644 --- a/src/Packages/Audience/Runtime/Core/Identity.cs +++ b/src/Packages/Audience/Runtime/Core/Identity.cs @@ -63,7 +63,7 @@ internal static void ClearCache() internal static string? GetOrCreate(string persistentDataPath, ConsentLevel consent) { // No ID until the player grants at least anonymous consent. - if (consent == ConsentLevel.None) + if (!consent.CanTrack()) return null; // Fast path — already loaded this session, no lock needed. diff --git a/src/Packages/Audience/Runtime/ImmutableAudience.cs b/src/Packages/Audience/Runtime/ImmutableAudience.cs index 826f1a452..e90ef7b46 100644 --- a/src/Packages/Audience/Runtime/ImmutableAudience.cs +++ b/src/Packages/Audience/Runtime/ImmutableAudience.cs @@ -192,7 +192,7 @@ public static void Identify(string userId, string identityType, Dictionary? msg) // Re-check consent inside the drain lock so a SetConsent(None) racing // the caller's CanTrack cannot leak this event past the purge. - queue.EnqueueChecked(msg, () => _consent != ConsentLevel.None); + queue.EnqueueChecked(msg, () => _consent.CanTrack()); } private static void SendBatch() @@ -632,7 +632,7 @@ private static void RescheduleSendTimer(HttpTransport transport) // landing between Init returning and here still drops the event. private static void FireGameLaunch(AudienceConfig config, ConsentLevel consentAtInit) { - if (consentAtInit == ConsentLevel.None) return; + if (!consentAtInit.CanTrack()) return; var properties = new Dictionary(); diff --git a/src/Packages/Audience/Tests/Runtime/ConsentLevelTests.cs b/src/Packages/Audience/Tests/Runtime/ConsentLevelTests.cs new file mode 100644 index 000000000..27ed22710 --- /dev/null +++ b/src/Packages/Audience/Tests/Runtime/ConsentLevelTests.cs @@ -0,0 +1,57 @@ +using System; +using NUnit.Framework; + +namespace Immutable.Audience.Tests +{ + [TestFixture] + internal class ConsentLevelTests + { + [TestCase(ConsentLevel.None, "none")] + [TestCase(ConsentLevel.Anonymous, "anonymous")] + [TestCase(ConsentLevel.Full, "full")] + public void ToLowercaseString_MapsEachEnumValueToLowercaseBackendString(ConsentLevel level, string expected) + { + Assert.AreEqual(expected, level.ToLowercaseString()); + } + + [Test] + public void ToLowercaseString_UnknownValue_Throws() + { + var invalid = (ConsentLevel)999; + + Assert.Throws(() => invalid.ToLowercaseString()); + } + + [TestCase(ConsentLevel.None, false)] + [TestCase(ConsentLevel.Anonymous, true)] + [TestCase(ConsentLevel.Full, true)] + public void CanTrack_TrueForAnonymousAndFull(ConsentLevel level, bool expected) + { + Assert.AreEqual(expected, level.CanTrack()); + } + + [Test] + public void CanTrack_UnknownValue_ReturnsTrue() + { + var invalid = (ConsentLevel)999; + + Assert.IsTrue(invalid.CanTrack()); + } + + [TestCase(ConsentLevel.None, false)] + [TestCase(ConsentLevel.Anonymous, false)] + [TestCase(ConsentLevel.Full, true)] + public void CanIdentify_TrueOnlyForFull(ConsentLevel level, bool expected) + { + Assert.AreEqual(expected, level.CanIdentify()); + } + + [Test] + public void CanIdentify_UnknownValue_ReturnsFalse() + { + var invalid = (ConsentLevel)999; + + Assert.IsFalse(invalid.CanIdentify()); + } + } +}