Withdrawal has a race condition
Multiple tabs, offline queues, stale projections, and disagreeing clocks turned a preference change into a protocol with one authoritative effective time.
I withdrew measurement consent in one tab and admitted an analytics event from another.
Both interfaces appeared correct. The settings page showed measurement off. The older tab still had measurement: true in memory and a queued event created seconds earlier. When its connection returned, it sent the batch. The collection service trusted the receipt reference attached by the client and stored the event.
The bug was not a missing toggle callback. It was a distributed ordering problem.
Preference existed in several places: an append-only receipt sequence, a current-state projection, browser memory, a cookie used during rendering, a local event queue, and a cache inside the collector. Each could observe a different moment. Client and server clocks disagreed. Network delivery reordered actions that the person had performed sequentially.
Q2F8 needed one precise promise. For optional categories in this personal experiment, once withdrawal became effective at the server, the collection gate would admit no new dependent event. Client creation time could not move a late request back across that boundary.
That choice discarded some events that may have been created under an earlier grant. It made withdrawal understandable and enforceable.
The browser contained several clocks
The initial implementation attached occurredAt to each event and compared it with the preference timeline. An event created before withdrawal could be accepted afterward.
That seemed fair until I listed the clocks involved:
tab A wall clock
tab B wall clock
service-worker or queue clock
collection server clock
preference server clock
database commit orderBrowser clocks can be wrong or adjusted. Tabs can be suspended and resume later. A queued event can claim an old occurrence time indefinitely. The server can receive preference and event requests in an order different from the person's actions.
Occurrence time remained useful as event context after admission. It was not trustworthy authority for whether admission was currently allowed.
The gate used its own admission time and the latest committed preference receipt visible under one consistent read. If withdrawal was effective before admission, the event was rejected. The client timestamp could not override that decision.
This created a clean boundary:
client time describes claimed observation
server receipt order establishes policy state
server admission time applies that stateDistributed systems rarely give one perfect clock. The protocol needed to choose which ordering carried authority for this decision.
Withdrawal became a committed receipt
Clicking the control did not make withdrawal effective instantly everywhere. The interface sent a request to create a new immutable preference receipt. The server validated the policy version, appended the receipt, updated the current projection, invalidated relevant caches, and returned its identity and effective time.
withdrawal requested locally
-> server validates predecessor and policy
-> receipt 23 committed
-> projection generation increments
-> caches invalidated
-> client receives receipt 23
-> interface confirms effective stateBefore confirmation, the settings page showed a pending change rather than claiming completion. Optional collection stopped locally as soon as the person requested withdrawal, a conservative client choice. If the request failed, the interface explained that the server state had not been confirmed and continued to suppress optional events until resolved.
The effective point was receipt commit, not click time and not when every tab heard about it.
This distinction avoided two dishonest states: showing success before authority changed, and continuing local collection while waiting for a privacy-reducing request to complete.
The receipt made the transition identifiable for every later rejection, deletion decision, and audit view.
Optimistic concurrency prevented lost choices
Two tabs could change different categories from the same old projection. A naive “write complete preference object” request allowed the later arrival to overwrite the earlier change.
Each request included the predecessor receipt it had observed:
baseReceipt: 17
changes: measurement=falseThe server appended only if the current receipt still matched the base or if the proposed change could be merged under an explicit rule. Otherwise it returned a conflict with the current state.
For privacy-reducing changes such as withdrawal, the server could safely apply a monotonic restriction even when another tab had changed an unrelated category. For grants or broader purpose acknowledgement, it required the client to refresh and present the current policy state.
This asymmetry was deliberate. A stale client could reduce optional capability without silently expanding it.
The resulting receipt referenced both predecessor and applied delta. A projection derived the current full set of categories.
Conflict handling prevented last-write-wins from turning a UI timing accident into a restored grant.
Other tabs were informed, not trusted
The browser broadcast the new receipt identity through same-origin communication. Active tabs updated quickly. Suspended tabs might receive it only after resuming. Private windows and different browser profiles remained separate.
Every tab treated broadcast as a cache invalidation signal:
receipt 23 exists; refresh current preference before optional collectionIt did not accept a peer tab's full state as authority. The receiving tab fetched or validated the signed server projection. If it could not confirm current state, optional collection remained disabled.
Local queues recorded the receipt generation under which an event had been created. On learning of a newer restrictive generation, the tab deleted queued consent-dependent events before transmission. It retained only safe operational counts such as “three optional events discarded,” not the payloads.
Broadcast improved responsiveness and minimization. The server gate remained necessary because no browser messaging scheme covers every stale or malicious client.
A notification can reduce the race window. It cannot become the policy boundary.
The hardest choice concerned events created under a grant but delivered after withdrawal.
One policy could accept them by creation time. That would preserve more analytics and require trusting a client timestamp, binding each event to a verifiable prior receipt, limiting queue age, and explaining that data could continue arriving after withdrawal.
Q2F8 chose a stricter rule for optional measurement:
Admission after the effective withdrawal is rejected, regardless of claimed creation time.
The tradeoff was visible. Offline or delayed analytics could be lost. The experiment did not depend on complete measurement, and the clearer user promise outweighed recovery.
The collection response marked the batch category_withdrawn. The client deleted it and did not retry. A rejected payload did not enter a general dead-letter queue, error sampler, or debug log.
Essential operational messages followed separate purpose rules and were never mislabeled as consent-dependent measurement merely to reuse the queue.
Grandfathering is a policy decision, not a technical inevitability. The protocol selected the rule it could explain and enforce.
Collector caches carried revocation risk
The collection service cached current preference projections to avoid a database read for every event. Under the first design, the entry lived for five minutes. Withdrawal could therefore be effective in the receipt store and ignored by a warm collector.
Cache lifetime had become withdrawal latency.
The revised design associated each projection with a generation. Committing a receipt incremented the subject's generation and published an invalidation. Before accepting a batch, the gate verified that its cached generation remained current through a lightweight authoritative marker.
If the marker service was unavailable, optional collection failed closed. A recently cached grant was not treated as permission to continue indefinitely.
The cache policy had explicit bounds and measurements:
maximum local age
invalidation delivery lag
generation-check failure rate
admission attempts under stale generationsTests paused invalidation and proved the generation check still rejected stale admission. They paused the authority store and proved optional events were not stored.
An optimization that can extend revoked capability is part of the policy protocol and must be reviewed as such.
The current projection could lag the ledger
Append-only receipts were the explanation source; a materialized current-state table supported fast reads. A background projection initially updated the table after the receipt committed.
That introduced a window in which the ledger contained withdrawal and the collection gate read the old current state.
For a boundary controlling new admission, eventual projection was insufficient unless the gate also checked ledger position. I changed the preference write so the receipt and authoritative current projection committed in one transaction. Secondary audit and reporting views could lag; admission state could not.
The receipt sequence remained append-only. The projection row contained current categories, policy version, latest receipt ID, and generation. Updating both atomically made the gate's read efficient and authoritative.
Downstream caches could still lag and were protected by generation validation. Historical audit views could rebuild from receipts if a projection defect appeared.
This separated two kinds of derived state:
authorization projection -> transactionally current
display and reporting projections -> may be eventually consistent with visible freshnessNot every read model needs the same consistency. The one granting storage authority did.
Clients sent small event batches. If the gate checked preference before each event, withdrawal could commit midway and split a batch unpredictably. If it checked only once without identity, the decision could not be audited.
The gate read one authoritative preference snapshot and assigned its receipt and generation to the batch decision. It then validated each event schema and category. All consent-dependent events in the batch were admitted or rejected under that snapshot, while unknown or invalid events could still fail individually.
The transaction was short. It did not lock preference state for the duration of downstream storage. Admission receipts recorded the snapshot identity. Withdrawal committing immediately after the read applied to the next batch.
This left a small, defined concurrency boundary: an event admitted under a grant read before withdrawal could finish storage after withdrawal. The audit could explain that its admission decision preceded the effective receipt.
The protocol did not claim that withdrawal erased already lawful admission retroactively. Retention and erasure policies handled existing records separately.
Defining the snapshot made the race observable rather than pretending there was a universal instant across services.
A client could time out while sending a batch and retry after withdrawal. The first attempt might have been admitted before withdrawal; the retry would now be rejected. Without stable identity, the system could duplicate or contradict its earlier outcome.
Each batch had an idempotency key derived from integration, subject, queue generation, and batch sequence. The collector stored a compact admission outcome keyed to that identity before acknowledging.
On retry:
- If the earlier batch had been admitted, the gate returned the existing receipt without storing it again.
- If it had been rejected, the gate returned the existing rejection.
- If no outcome existed, current policy applied.
- If outcome was unknown due to an incomplete commit, processing paused for reconciliation rather than guessing.
Withdrawal did not rewrite a completed prior admission. It controlled new decisions.
This distinction prevented a retry after withdrawal from creating a second record or changing history. It also prevented a missing response from being interpreted as proof that nothing had happened.
Privacy workflows inherit the same idempotency and unknown-outcome problems as payments and deployments.
The UI separated requested, effective, and propagated
A single toggle could not represent the transition honestly. Q2F8's settings view used three internal states:
current confirmed preference
pending requested change
effective server receiptAfter server confirmation, the primary status said measurement was off. A secondary note could indicate that other open tabs had been notified, but it did not wait for acknowledgements from every tab before calling withdrawal effective. The server no longer trusted those tabs.
If the request failed, the control retained the person's requested off state locally, showed that server confirmation was unresolved, and offered retry. Optional collection remained suppressed in that browser.
The interface avoided language such as “all your data has been deleted.” Withdrawal of future consent-dependent collection, retention of previously admitted records, and account erasure were different workflows.
The settings page linked to the current retention or deletion state where the test policy required it.
Clear state language prevented a distributed protocol from collapsing into a deceptively instantaneous switch.
Existing records followed their declared lifecycle
Withdrawal did not automatically mean every previously admitted event was unlawful or should vanish immediately. In the experiment, each event definition stated what withdrawal changed for retained data: continue under the original short retention, schedule irreversible aggregation and delete raw records, or enqueue account-linked deletion.
The policy was chosen before collection and shown in the settings detail. The withdrawal receipt triggered the appropriate lifecycle workflow but did not improvise deletion semantics after the fact.
This nuance mattered. Saying “withdrawal deletes everything” would be false for receipts or narrow operational records retained under a separate purpose. Saying “withdrawal only stops future tracking” could ignore declared deletion obligations.
Q2F8 kept three actions distinct:
withdraw preference -> no new dependent admission
retention expiry -> remove records after purpose deadline
erasure request -> execute a registered cross-store workflow where applicableEach produced its own receipt and user-facing status.
The race condition belonged primarily to new admission. Existing data still needed an explicit lifecycle, not assumptions borrowed from the toggle.
If an event's purpose broadened or a category definition changed materially, old acknowledgement could no longer be treated as current permission in the experiment.
The policy service issued a new version and marked affected categories acknowledgement_required. Collectors rejected dependent events under older receipts until a new preference receipt referenced the new policy.
Tabs received the version change through the same invalidation path as withdrawal. Unknown state defaulted to no optional collection. Queued events under the old policy were discarded rather than reinterpreted under the new one.
This prevented a policy text update from racing with event delivery and quietly granting broader meaning to an old receipt.
Not every copy edit triggered re-acknowledgement. The version diff classified whether purpose, data, or consequence changed. The system required human review of that classification; it could not decide materiality from text alone.
Policy change was another revocation of a previous admission condition until a new one became effective.
Tests controlled time and delivery order
Happy-path tests could not expose the race. The suite used a controllable server clock, explicit receipt transactions, and a queue harness that reordered delivery.
Scenarios included:
- Event created before withdrawal, delivered after.
- Event and withdrawal requests arriving concurrently.
- Withdrawal committed while collector cache remained stale.
- Projection update failure inside the receipt transaction.
- Two tabs granting and withdrawing from the same base receipt.
- Batch retry after an earlier admitted response was lost.
- Policy version changing while a queue remained offline.
- Preference authority unavailable with a cached grant.
Each scenario asserted admission receipts, storage absence or presence, rejection reasons, cache generations, and user-visible state. Rejected payloads were checked against logs and dead-letter stores to ensure refusal did not create another retention path.
Property-based ordering tests generated sequences of grant, withdraw, create, deliver, retry, and policy change. The core invariant remained:
No new consent-dependent admission uses a policy snapshot
older than the latest effective restrictive receipt at its decision point.Controlling time made policy behavior testable without pretending real clocks were synchronized.
The system recorded safe operational measures:
- Time from withdrawal request to receipt commit.
- Cache invalidation lag.
- Admission attempts under stale generations.
- Rejections after withdrawal by integration and event name.
- Queued events discarded by clients.
- Preference write conflicts.
- Unknown admission outcomes awaiting reconciliation.
It did not record rejected event values. Monitoring the privacy gate could not become a second analytics stream.
A canary synthetic subject granted measurement, admitted a known event, withdrew, then attempted another event from a stale client fixture. The expected result was one accepted record and one rejection tied to the effective receipt.
If the canary event after withdrawal appeared in purpose storage, the alert named the violated invariant and froze optional admission while investigation occurred.
The promise needed operational evidence after tests passed. A race can reappear through cache, deployment, or queue changes far from the preference UI.
The loss was intentional
The strict rule discarded legitimate pre-withdrawal observations delayed by offline queues. It reduced analytical completeness and made some mobile or intermittent sessions underrepresented.
I documented that bias beside every analysis. The system did not compensate by inferring missing events or extending queue acceptance. If complete offline analytics ever became essential, it would require a new explicit policy with verifiable creation receipts and a clear explanation that delivery could continue after withdrawal.
For Q2F8's optional measurement experiment, simplicity and user control won.
This was not a free privacy improvement. It exchanged data completeness for a crisp temporal guarantee. Naming the loss prevented later engineers—again, future me—from “fixing” it as an unreliable queue.
Architecture always embeds priority. Here, the effect of withdrawal took priority over recovering every event.
Withdrawal became a protocol boundary
The settings toggle remained small. Behind it, the system needed:
an immutable server receipt
optimistic concurrency on preference changes
one authoritative current projection
generation-aware cache invalidation
client queue deletion
stable batch identity
server-time admission
explicit retained-data lifecycle
tests that reorder clocks and messages
monitoring that does not retain rejected payloadsThe complexity did not argue against allowing withdrawal. It revealed complexity the initial banner had hidden.
A privacy promise crosses distributed state. If its implementation assumes tabs refresh, queues drain in order, caches expire quickly enough, and client timestamps are honest, the promise is conditional on the easiest execution path.
Q2F8 chose a narrow invariant it could uphold: after the server commits withdrawal, optional dependent events do not receive new admission, even when they claim an earlier origin.
The race condition did not disappear. The protocol placed an authoritative line through it and made every side of that line observable.