fix(audience): atomic ConsentState for Full→Anonymous userId race (SDK-233)#700
Merged
Conversation
015b13f to
2037716
Compare
nattb8
reviewed
Apr 23, 2026
nattb8
reviewed
Apr 23, 2026
nattb8
previously approved these changes
Apr 23, 2026
150277b to
08cfe25
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 08cfe25. Configure here.
ImmutableJeffrey
added a commit
that referenced
this pull request
Apr 23, 2026
Fires the fire-and-forget consent PUT after the lock releases. JSON serialisation, dictionary allocation, and Task.Run scheduling no longer block concurrent SetConsent / Identify / Reset callers. Why: the call's three args are already local snapshots and the control client it reads is not _initLock-protected anyway (Shutdown disposes it without the lock, relying on _shutdownCancellationSource for safety). Tests: all 181 audience unit tests pass. No test changes — Identity.Reset still runs before the PUT, so the revocation regression guard still holds. Addresses Cursor Bugbot comment on #700. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
nattb8
previously approved these changes
Apr 23, 2026
The prior design had `_consent` and `_userId` as separate volatiles. SetConsent(Full → Anonymous) wrote them in two steps; a concurrent Track that read _userId before the null-write and _consent after the flip could enqueue a userId-bearing track that ApplyAnonymousDowngrade's one-shot disk rewrite never saw. - Pack (Level, UserId) as an immutable ConsentState record. A single volatile reference swap publishes both together; readers see a consistent pair. - EnqueueChecked takes a transform callback that runs under _drainLock. Track's transform re-reads _state and strips userId when the level is not Full, serialised against ApplyAnonymousDowngrade. - Identify, Reset, and SetConsent take _initLock for the state swap so writes don't lose the pair's atomicity. - Identify/Alias enqueue with a gate that drops the event if consent has since downgraded below Full. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors SetConsent_DowngradeToNone_StressTest_NoLeak but exercises the Full → Anonymous path: an Identify-set userId must not leak through a racing Track past ApplyAnonymousDowngrade. Without the EnqueueChecked transform (commit c14cd391), this leaks reproducibly. With the fix, zero leaks across 200 × 4 iterations. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites jargon-heavy comments (polyfill, atomic reference, release/ acquire, drain-lock, serialise) into plain language. No code changes. All 181 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dead code: every callsite uses the extension method ConsentLevel.CanTrack() directly. No call to the private static CanTrack() remained. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two comments still named the pre-refactor _consent field; one class-level comment enumerated only Identify / Reset as taking _initLock, which is now true of every _state write. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Fires the fire-and-forget consent PUT after the lock releases. JSON serialisation, dictionary allocation, and Task.Run scheduling no longer block concurrent SetConsent / Identify / Reset callers. Why: the call's three args are already local snapshots and the control client it reads is not _initLock-protected anyway (Shutdown disposes it without the lock, relying on _shutdownCancellationSource for safety). Tests: all 181 audience unit tests pass. No test changes — Identity.Reset still runs before the PUT, so the revocation regression guard still holds. Addresses Cursor Bugbot comment on #700. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
a1ee630 to
815c563
Compare
nattb8
approved these changes
Apr 23, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
ConsentState— an immutable record packing(ConsentLevel, UserId)._stateis avolatile ConsentState, so a single reference swap publishes both fields atomically and readers never observe(Anonymous, oldUserId)._initLockinIdentify,Reset, andSetConsentaround the state swap so the pair stays atomic against concurrent callers.EnqueueCheckedsignature: replacesFunc<bool>? stillAllowedwithFunc<Dictionary<string, object>, Dictionary<string, object>?>? transform. The transform runs under_drainLockand returns the (possibly mutated) msg, ornullto drop._stateinside_drainLockat enqueue time:EnqueueTrackstripsuserIdwhen the current level is not Full;EnqueueIdentitydrops when the current level is not Full. Closes the race where aTrackracingSetConsent(Full → Anonymous)could land an old-userId msg on disk afterApplyAnonymousDowngradehad already rewritten the queue.Tracks racingSetConsent(Full → Anonymous)afterIdentify, asserting zero userId leaks on disk.IsExternalInit—record { init; }requires it andnetstandard2.1(Unity) does not ship it.Linear:
ConsentStatemakesSetConsentcallable from any threadNote
Medium Risk
Touches consent/identity handling and concurrency around event queuing, where mistakes could cause userId leakage or dropped events. Changes are localized and backed by new stress coverage, but affect a privacy-sensitive path.
Overview
Prevents privacy-related race conditions by replacing separate volatile
_consent/_userIdfields with a singlevolatileConsentStaterecord and updatingIdentify,Reset,Shutdown,ResetState, andSetConsentto swap this state under_initLock.Hardens queuing against concurrent consent changes by changing
EventQueue.EnqueueCheckedto accept an under-_drainLocktransform (mutate/drop message), and routingTrack/Identify/Aliasthrough newEnqueueTrack/EnqueueIdentityhelpers that re-read current consent to drop events underNoneand stripuserIdwhen notFull. Adds a stress test covering the Full→Anonymous race and a UnityIsExternalInitpolyfill to support the new record type.Reviewed by Cursor Bugbot for commit a1ee630. Bugbot is set up for automated code reviews on this repo. Configure here.