From 7a4c50415b65d4e75693fb80288bfcc18fdbb980 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Wed, 12 Nov 2025 09:33:58 +0900 Subject: [PATCH 1/7] Update api.bs --- api.bs | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 348 insertions(+), 12 deletions(-) diff --git a/api.bs b/api.bs index 3e8b23f..d0977f2 100644 --- a/api.bs +++ b/api.bs @@ -1068,14 +1068,13 @@ For example, "`extra.example.com`" is parsed as "`example.com`". ## State For Privacy Budget Management ## {#privacy-state} -[=User agents=] maintain three pieces of state +[=User agents=] maintain several pieces of state that are used to manage the expenditure of [=privacy budgets=]: * The [=privacy budget store=] records the state of the per-[=site=] and per-[=epoch=] [=privacy budgets=]. It is updated by [=deduct privacy budget=]. - * The [=epoch start store=] records when each [=epoch=] starts for [=conversion sites=]. This store is initialized as a side effect @@ -1084,19 +1083,26 @@ that are used to manage the expenditure of [=privacy budgets=]: * A singleton [=last browsing history clear=] value that tracks when the browsing activity for a [=site=] was last cleared. +* The [=global privacy budget store=] records the state + of the per-[=epoch=] global [=privacy budget=] + that applies across all [=sites=]. + +* The [=impression site quota store=] records the state + of per-[=impression site=] and per-[=epoch=] quota [=privacy budgets=]. + +* The [=conversion site quota store=] records the state + of per-[=conversion site=] and per-[=epoch=] quota [=privacy budgets=]. + +* The [=user action context store=] records which [=sites=] + have accessed quota [=privacy budgets=] within the current [=user action context=]. +

Like the [=impression store=], -the [=privacy budget store=] does not use a [=storage key=]. +the [=privacy budget store=] and related stores do not use a [=storage key=]. These stores have some additional constraints on how information is cleared; see [[#clear-budget-store]] for details. -

-The [=safety limits=] need to be described in more detail. -Some references to clearing -the [=impression store=] may need to be -updated to refer to the [=privacy budget store=] as well. - ### Privacy Budget Store ### {#s-privacy-budget-store} @@ -1173,6 +1179,218 @@ is added to the aggregated histogram. +

+To check and deduct safety limit budgets for impression sites, +given [=epoch index=] |epoch|, +[=set=] of [=impression sites=] |impressionSites|, +[=conversion site=] |conversionSite|, +[=user action context=] |uaContext|, +[[WEBIDL#idl-double|double]] |epsilon|, +integer |value|, +integer |maxValue|, +and nullable integer |l1Norm|: + +1. Let |sensitivity| be |l1Norm| if |l1Norm| is non-null, 2 * |value| otherwise. + +1. Let |noiseScale| be 2 * |maxValue| / |epsilon|. + +1. Let |deductionFp| be |sensitivity| / |noiseScale|. + +1. If |deductionFp| is negative or greater than [=maximum epsilon=], + return false. + +1. Let |deduction| be |deductionFp| * 1000000, rounded towards positive Infinity. + +1. Let |accessedSites| be the [=map/get|value=] of |uaContext| + in the [=user action context store=], + or an empty [=set=] if not present. + +1. Let |newSiteCount| be 0. + +1. [=set/iterate|For each=] |impressionSite| in |impressionSites|: + + 1. If |accessedSites| does not [=set/contain=] |impressionSite|, + increment |newSiteCount| by 1. + +1. If |accessedSites| does not [=set/contain=] |conversionSite|, + increment |newSiteCount| by 1. + +1. If ([=set/size=] of |accessedSites|) + |newSiteCount| + is greater than the [=quota count cap=], return false. + +

This check enforces the quota-count cap (kquota-count) + from the Big Bird algorithm, which limits how many distinct sites + can create new quota budgets within a single user action. + +1. Let |globalBudget| be the [=map/get|value=] of |epoch| + in the [=global privacy budget store=], + or the [=global budget per epoch=] if not present. + +1. If |deduction| is greater than |globalBudget|, return false. + +1. [=set/iterate|For each=] |impressionSite| in |impressionSites|: + + 1. Let |impressionKey| be an [=impression site quota key=] + with |epoch| and |impressionSite|. + + 1. Let |impressionQuota| be the [=map/get|value=] of |impressionKey| + in the [=impression site quota store=], + or the [=impression site quota per epoch=] if not present. + + 1. If |deduction| is greater than |impressionQuota|, return false. + +1. Let |conversionKey| be a [=conversion site quota key=] + with |epoch| and |conversionSite|. + +1. Let |conversionQuota| be the [=map/get|value=] of |conversionKey| + in the [=conversion site quota store=], + or the [=conversion site quota per epoch=] if not present. + +1. If |deduction| is greater than |conversionQuota|, return false. + +

The above steps implement the "Check" phase of the atomic two-phase commit protocol + from the Big Bird algorithm. All budget checks (quota-count, global budget, impression-site quotas, + and conversion-site quota) must succeed before any deductions are made. + If any check fails, the transaction aborts and NO budgets are deducted. + +1. [=map/Set=] the [=global privacy budget store=]\[|epoch|] + to |globalBudget| − |deduction|. + +1. [=set/iterate|For each=] |impressionSite| in |impressionSites|: + + 1. Let |impressionKey| be an [=impression site quota key=] + with |epoch| and |impressionSite|. + + 1. Let |impressionQuota| be the [=map/get|value=] of |impressionKey| + in the [=impression site quota store=], + or the [=impression site quota per epoch=] if not present. + + 1. [=map/Set=] the [=impression site quota store=]\[|impressionKey|] + to |impressionQuota| − |deduction|. + +1. [=map/Set=] the [=conversion site quota store=]\[|conversionKey|] + to |conversionQuota| − |deduction|. + +

The above steps implement the "Consume" phase of the atomic transaction. + Since all checks passed, we now commit by deducting from all relevant budgets. + +1. [=set/iterate|For each=] |impressionSite| in |impressionSites|: + + 1. [=set/Append=] |impressionSite| to |accessedSites|. + +1. [=set/Append=] |conversionSite| to |accessedSites|. + +1. [=map/Set=] the [=user action context store=]\[|uaContext|] + to |accessedSites|. + +1. Return true. + +

+ +### Global Privacy Budget Store ### {#s-global-privacy-budget-store} + +The global privacy budget store is a [=map=] whose keys are +[=epoch indices=] and whose values are [=32-bit unsigned integers=] +in units of [=microepsilons=]. + +The [=global privacy budget store=] enforces a single [=privacy budget=] +per [=epoch=] that applies across all [=sites=]. +This provides a [=safety limit=] against adversaries +that can correlate activity for the same person across multiple [=sites=]. + +

Unlike the per-[=site=] [=privacy budget store=], +the [=global privacy budget store=] is keyed only by [=epoch index=], +not by [=site=]. + + +### Impression Site Quota Store ### {#s-impression-site-quota-store} + +The impression site quota store is a [=map=] whose keys are +[=impression site quota keys=] and whose values are [=32-bit unsigned integers=] +in units of [=microepsilons=]. + +An impression site quota key is a [=tuple=] consisting of the following items: + +

+: epoch index +:: An [=epoch index=] +: impression site +:: An [=impression site=] + +
+ +The [=impression site quota store=] limits the amount of "stock" +(privacy budget related to [=impressions=]) +that any single [=impression site=] can contribute in an [=epoch=]. +This prevents a single [=impression site=] +from enabling excessive budget +that could be maliciously triggered. + + +### Conversion Site Quota Store ### {#s-conversion-site-quota-store} + +The conversion site quota store is a [=map=] whose keys are +[=conversion site quota keys=] and whose values are [=32-bit unsigned integers=] +in units of [=microepsilons=]. + +A conversion site quota key is a [=tuple=] consisting of the following items: + +
+: epoch index +:: An [=epoch index=] +: conversion site +:: A [=conversion site=] + +
+ +The [=conversion site quota store=] limits the amount of "flow" +(privacy budget consumed by reports) +that any single [=conversion site=] can trigger in an [=epoch=]. +This constrains the budget that can be drawn by a [=conversion site=], +limiting its ability to rapidly deplete the [=global privacy budget=]. + + +### User Action Context Store ### {#s-user-action-context-store} + +The user action context store is a [=map=] keyed by [=user action contexts=] +and containing values that are [=sets=] of [=sites=]. + +A user action context is an identifier +for a sequence of API invocations +that are associated with a single intentional user action, +such as a navigation or click. + +The [=user action context store=] tracks which [=sites=] +have accessed quota [=privacy budgets=] +(either [=impression site quota store|impression site quotas=] +or [=conversion site quota store|conversion site quotas=]) +within the current [=user action context=]. +This enables enforcement of the [=quota count cap=]. + +

A [=user action context=] typically corresponds to +a top-level navigation or other substantial user interaction. +[=User agents=] determine when a new [=user action context=] begins +based on their understanding of intentional user actions. + +

+To get the current user action context, +returning a [=user action context=]: + +1. If the [=user agent=] has an active [=user action context=] + associated with the current execution context, return it. + +1. Otherwise, create a new [=user action context=] identifier, + add it to the [=user action context store=] with an empty [=set=] value, + and return it. + +

The [=user agent=] determines when [=user action contexts=] expire +and are removed from the [=user action context store=]. +Contexts typically expire after some period of inactivity +or when a new top-level navigation occurs. + +

+ + ### Epoch Start Store ### {#s-epoch-start} An [=epoch=] starts at a randomly-selected time @@ -1248,6 +1466,38 @@ returning an [=epoch index=]: +### Safety Limits Configuration ### {#safety-limits-configuration} + +[=User agents=] configure [=safety limits=] by defining the following values: + +* Global budget per epochglobal): + The maximum privacy budget available across all [=sites=] per [=epoch=], + specified in [=microepsilons=]. + +* Impression site quota per epochimp-quota): + The maximum privacy budget that a single [=impression site=] + can contribute to the [=global privacy budget=] per [=epoch=], + specified in [=microepsilons=]. + +* Conversion site quota per epochconv-quota): + The maximum privacy budget that a single [=conversion site=] + can consume from the [=global privacy budget=] per [=epoch=], + specified in [=microepsilons=]. + +* Quota count cap (kquota-count): + The maximum number of distinct [=sites=] + that can create new quota budgets + within a single [=user action context=]. + +

Typical values might be: +εglobal = 10 × εconv-quota, +εconv-quota = 1.5 × εquerier, +εimp-quota = n × 1.5 × εquerier, +where εquerier is a typical per-site budget (e.g., 1.0) +and n is the expected number of [=conversion sites=] +that query a single [=impression site=] (e.g., 3-5). + + ### Last Browsing History Clear Time ### {#last-clear} The last browsing history clear is a [=moment=] @@ -1367,6 +1617,10 @@ and a [=moment=] |now|: 1. [=map/clear|Clear=] the [=epoch start store=]. +

TODO: Define how to clear [=safety limits=] stores: + [=global privacy budget store=], [=impression site quota store=], + [=conversion site quota store=], and [=user action context store=]. + 1. If |sites| [=set/is empty|is not empty=]: 1. [=set/iterate|For each=] |impression| in the [=impression store=], @@ -1438,6 +1692,26 @@ The saveImpression(|options|) method steps are 1. If any result in |conversionCallers| is failure, return [=a promise rejected with=] a {{"SyntaxError"}} {{DOMException}} in |realm|. 1. Run the following steps [=in parallel=]: + 1. Let |uaContext| be the [=current user action context=]. + + 1. Let |accessedSites| be the [=map/get|value=] of |uaContext| + in the [=user action context store=], + or an empty [=set=] if not present. + + 1. If |accessedSites| does not [=set/contain=] |site|: + + 1. If the [=set/size=] of |accessedSites| is greater than or equal to + the [=quota count cap=], return. + +

This implements the quota-count cap check from the Big Bird algorithm, + preventing a single user action from creating new impression-site quotas + for more than [=quota count cap=] distinct sites. + + 1. [=set/Append=] |site| to |accessedSites|. + + 1. [=map/Set=] the [=user action context store=]\[|uaContext|] + to |accessedSites|. + 1. Construct |impression| as a [=impression|saved impression=] comprising: : [=impression/Match Value=] :: |options|.{{AttributionImpressionOptions/matchValue}} @@ -1459,6 +1733,10 @@ The saveImpression(|options|) method steps are :: |options|.{{AttributionImpressionOptions/priority}} 1. If the Attribution API is [[#opt-out|enabled]], save |impression| to the [=impression store=]. + +

Impressions are stored with their timestamp, + which is later used to determine which [=epoch=] they belong to + when matching during conversion measurement. 1. Let |result| be a new {{AttributionImpressionResult}}. 1. Return [=a promise resolved with=] |result| in |realm|. @@ -1496,6 +1774,10 @@ The measureConversion(|options|) method steps 1. otherwise, the result of [=obtain a site|obtaining a site=] from |settings|' [=environment settings object/origin=]. + 1. Let |uaContext| be the [=current user action context=]. + +

The [=user agent=] determines when a new [=user action context=] begins, + typically corresponding to a top-level navigation or other substantial user interaction. 1. Let |validatedOptions| be the result of [=validate AttributionConversionOptions|validating=] |options|, returning [=a promise rejected with=] any thrown reason. @@ -1505,7 +1787,7 @@ The measureConversion(|options|) method steps |validatedOptions|' [=validated conversion options/histogram size=]. 1. If the Attribution API is [[#opt-out|enabled]], set |report| to the result of [=do attribution and fill a histogram=] with |validatedOptions|, - |topLevelSite|, |intermediarySite|, and |now|. + |topLevelSite|, |intermediarySite|, |uaContext|, and |now|. 1. Let |aggregationService| be |validatedOptions|'s [=validated conversion options/aggregation service=]. 1. Switch on the value of |aggregationService|.{{AttributionAggregationService/protocol}}:

@@ -1628,6 +1910,7 @@ To do attribution and fill a histogram, given [=validated conversion options=] |options|, [=site=] |topLevelSite|, [=site=] or `undefined` |intermediarySite|, + [=user action context=] |uaContext|, and [=moment=] |now|: 1. Let |matchedImpressions| be an [=set/is empty|empty=] [=set=]. @@ -1649,13 +1932,31 @@ To do attribution and fill a histogram, given 1. If |singleEpoch| is false: +

When attributing across multiple [=epochs=], + each [=epoch=]'s [=impressions=] are evaluated separately. + [=Impressions=] are organized by [=epoch=] based on their [=impression/timestamp=] + relative to the [=conversion site=]. + For each [=epoch=], both the per-[=site=] [=privacy budget=] and [=safety limits=] + are checked and deducted independently using an atomic transaction. + This approach ensures that budget consumption is properly tracked across time periods. + 1. For each |epoch| from |startEpoch| to |currentEpoch|, inclusive: 1. Let |impressions| be the result of invoking [=common matching logic=] with |options|, |topLevelSite|, |intermediarySite|, |epoch|, and |now|. +

The [=common matching logic=] filters [=impressions=] + from the [=impression store=] by comparing each impression's [=impression/timestamp=] + against |topLevelSite| to determine which [=epoch=] it belongs to. + Only [=impressions=] that fall within the current |epoch| are selected. + 1. If |impressions| [=set/is empty|is not empty=]: + 1. Let |impressionSites| be an [=set/is empty|empty=] [=set=]. + + 1. [=set/iterate|For each=] |impression| in |impressions|, + [=set/append=] |impression|'s [=impression/impression site=] to |impressionSites|. + 1. Let |key| be a [=privacy budget key=] whose items are |epoch| and |topLevelSite|. 1. Let |budgetOk| be the result of invoking [=deduct privacy budget=] @@ -1664,12 +1965,37 @@ To do attribution and fill a histogram, given |options|'s [=validated conversion options/max value=], and null. - 1. If |budgetOk| is true, [=set/extend=] |matchedImpressions| with |impressions|. + 1. Let |safetyOk| be the result of invoking [=check and deduct safety limit budgets for impression sites=] + with |epoch|, + |impressionSites|, + |topLevelSite|, + |uaContext|, + |options|' [=validated conversion options/epsilon=], + |options|' [=validated conversion options/value=], + |options|'s [=validated conversion options/max value=], + and null. + + 1. If |budgetOk| is true and |safetyOk| is true, [=set/extend=] |matchedImpressions| with |impressions|. + +

If either the per-[=site=] [=privacy budget=] or the [=safety limits=] + for a given [=epoch=] are insufficient, [=impressions=] from that [=epoch=] are excluded + from attribution (by dropping that epoch's data), but [=impressions=] from other [=epochs=] + with sufficient budget may still be included. + This implements the per-epoch atomic transaction pattern from the Big Bird algorithm. 1. If |matchedImpressions| [=set/is empty=], return the result of invoking [=create an all-zero histogram=] with |options|' [=validated conversion options/histogram size=]. +1. Let |impressionSites| be an [=set/is empty|empty=] [=set=]. + +1. [=set/iterate|For each=] |impression| in |matchedImpressions|, + [=set/append=] |impression|'s [=impression/impression site=] to |impressionSites|. + +

The [=common matching logic=] can select [=impressions=] + from multiple [=impression sites=], + such as to support cross-publisher attribution. + 1. Set |histogram| to the result of [=fill a histogram with last-n-touch attribution=] with |matchedImpressions|, |options|' [=validated conversion options/histogram size=], |options|' [=validated conversion options/value=], and @@ -1688,7 +2014,17 @@ To do attribution and fill a histogram, given |options|'s [=validated conversion options/max value=], and |l1Norm|. - 1. If |budgetOk| is false, set |histogram| to the result of invoking + 1. Let |safetyOk| be the result of invoking [=check and deduct safety limit budgets for impression sites=] + with |currentEpoch|, + |impressionSites|, + |topLevelSite|, + |uaContext|, + |options|' [=validated conversion options/epsilon=], + |options|' [=validated conversion options/value=], + |options|'s [=validated conversion options/max value=], + and |l1Norm|. + + 1. If |budgetOk| is false or |safetyOk| is false, set |histogram| to the result of invoking [=create an all-zero histogram=] with |options|' [=validated conversion options/histogram size=]. 1. Return |histogram|. From 3db436805ef4601024d05c8b64a4516d06aac230 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Wed, 12 Nov 2025 11:14:33 +0900 Subject: [PATCH 2/7] update safety limit draft --- api.bs | 175 +++------------------------------------------------------ 1 file changed, 9 insertions(+), 166 deletions(-) diff --git a/api.bs b/api.bs index d0977f2..3dc0dc2 100644 --- a/api.bs +++ b/api.bs @@ -1103,6 +1103,11 @@ These stores have some additional constraints on how information is cleared; see [[#clear-budget-store]] for details. +

+Some references to clearing +the [=impression store=] may need to be +updated to refer to the [=privacy budget store=] as well. + ### Privacy Budget Store ### {#s-privacy-budget-store} @@ -1179,111 +1184,7 @@ is added to the aggregated histogram. -

-To check and deduct safety limit budgets for impression sites, -given [=epoch index=] |epoch|, -[=set=] of [=impression sites=] |impressionSites|, -[=conversion site=] |conversionSite|, -[=user action context=] |uaContext|, -[[WEBIDL#idl-double|double]] |epsilon|, -integer |value|, -integer |maxValue|, -and nullable integer |l1Norm|: - -1. Let |sensitivity| be |l1Norm| if |l1Norm| is non-null, 2 * |value| otherwise. - -1. Let |noiseScale| be 2 * |maxValue| / |epsilon|. - -1. Let |deductionFp| be |sensitivity| / |noiseScale|. - -1. If |deductionFp| is negative or greater than [=maximum epsilon=], - return false. - -1. Let |deduction| be |deductionFp| * 1000000, rounded towards positive Infinity. - -1. Let |accessedSites| be the [=map/get|value=] of |uaContext| - in the [=user action context store=], - or an empty [=set=] if not present. - -1. Let |newSiteCount| be 0. - -1. [=set/iterate|For each=] |impressionSite| in |impressionSites|: - - 1. If |accessedSites| does not [=set/contain=] |impressionSite|, - increment |newSiteCount| by 1. - -1. If |accessedSites| does not [=set/contain=] |conversionSite|, - increment |newSiteCount| by 1. - -1. If ([=set/size=] of |accessedSites|) + |newSiteCount| - is greater than the [=quota count cap=], return false. - -

This check enforces the quota-count cap (kquota-count) - from the Big Bird algorithm, which limits how many distinct sites - can create new quota budgets within a single user action. - -1. Let |globalBudget| be the [=map/get|value=] of |epoch| - in the [=global privacy budget store=], - or the [=global budget per epoch=] if not present. - -1. If |deduction| is greater than |globalBudget|, return false. - -1. [=set/iterate|For each=] |impressionSite| in |impressionSites|: - - 1. Let |impressionKey| be an [=impression site quota key=] - with |epoch| and |impressionSite|. - - 1. Let |impressionQuota| be the [=map/get|value=] of |impressionKey| - in the [=impression site quota store=], - or the [=impression site quota per epoch=] if not present. - - 1. If |deduction| is greater than |impressionQuota|, return false. - -1. Let |conversionKey| be a [=conversion site quota key=] - with |epoch| and |conversionSite|. - -1. Let |conversionQuota| be the [=map/get|value=] of |conversionKey| - in the [=conversion site quota store=], - or the [=conversion site quota per epoch=] if not present. -1. If |deduction| is greater than |conversionQuota|, return false. - -

The above steps implement the "Check" phase of the atomic two-phase commit protocol - from the Big Bird algorithm. All budget checks (quota-count, global budget, impression-site quotas, - and conversion-site quota) must succeed before any deductions are made. - If any check fails, the transaction aborts and NO budgets are deducted. - -1. [=map/Set=] the [=global privacy budget store=]\[|epoch|] - to |globalBudget| − |deduction|. - -1. [=set/iterate|For each=] |impressionSite| in |impressionSites|: - - 1. Let |impressionKey| be an [=impression site quota key=] - with |epoch| and |impressionSite|. - - 1. Let |impressionQuota| be the [=map/get|value=] of |impressionKey| - in the [=impression site quota store=], - or the [=impression site quota per epoch=] if not present. - - 1. [=map/Set=] the [=impression site quota store=]\[|impressionKey|] - to |impressionQuota| − |deduction|. - -1. [=map/Set=] the [=conversion site quota store=]\[|conversionKey|] - to |conversionQuota| − |deduction|. - -

The above steps implement the "Consume" phase of the atomic transaction. - Since all checks passed, we now commit by deducting from all relevant budgets. - -1. [=set/iterate|For each=] |impressionSite| in |impressionSites|: - - 1. [=set/Append=] |impressionSite| to |accessedSites|. - -1. [=set/Append=] |conversionSite| to |accessedSites|. - -1. [=map/Set=] the [=user action context store=]\[|uaContext|] - to |accessedSites|. - -1. Return true.

@@ -1476,7 +1377,7 @@ returning an [=epoch index=]: * Impression site quota per epochimp-quota): The maximum privacy budget that a single [=impression site=] - can contribute to the [=global privacy budget=] per [=epoch=], + can enable to be consumed from the [=global privacy budget=] per [=epoch=], specified in [=microepsilons=]. * Conversion site quota per epochconv-quota): @@ -1490,12 +1391,7 @@ returning an [=epoch index=]: within a single [=user action context=].

Typical values might be: -εglobal = 10 × εconv-quota, -εconv-quota = 1.5 × εquerier, -εimp-quota = n × 1.5 × εquerier, -where εquerier is a typical per-site budget (e.g., 1.0) -and n is the expected number of [=conversion sites=] -that query a single [=impression site=] (e.g., 3-5). +TODO ### Last Browsing History Clear Time ### {#last-clear} @@ -1692,26 +1588,6 @@ The saveImpression(|options|) method steps are 1. If any result in |conversionCallers| is failure, return [=a promise rejected with=] a {{"SyntaxError"}} {{DOMException}} in |realm|. 1. Run the following steps [=in parallel=]: - 1. Let |uaContext| be the [=current user action context=]. - - 1. Let |accessedSites| be the [=map/get|value=] of |uaContext| - in the [=user action context store=], - or an empty [=set=] if not present. - - 1. If |accessedSites| does not [=set/contain=] |site|: - - 1. If the [=set/size=] of |accessedSites| is greater than or equal to - the [=quota count cap=], return. - -

This implements the quota-count cap check from the Big Bird algorithm, - preventing a single user action from creating new impression-site quotas - for more than [=quota count cap=] distinct sites. - - 1. [=set/Append=] |site| to |accessedSites|. - - 1. [=map/Set=] the [=user action context store=]\[|uaContext|] - to |accessedSites|. - 1. Construct |impression| as a [=impression|saved impression=] comprising: : [=impression/Match Value=] :: |options|.{{AttributionImpressionOptions/matchValue}} @@ -1734,9 +1610,6 @@ The saveImpression(|options|) method steps are 1. If the Attribution API is [[#opt-out|enabled]], save |impression| to the [=impression store=]. -

Impressions are stored with their timestamp, - which is later used to determine which [=epoch=] they belong to - when matching during conversion measurement. 1. Let |result| be a new {{AttributionImpressionResult}}. 1. Return [=a promise resolved with=] |result| in |realm|. @@ -1929,34 +1802,16 @@ To do attribution and fill a histogram, given 1. If |singleEpoch| is true: 1. Set |matchedImpressions| to the result of invoking [=common matching logic=] with |options|, |topLevelSite|, |intermediarySite|, |currentEpoch|, and |now|. + 1. TODO safety limits in single-epoch case. 1. If |singleEpoch| is false: - -

When attributing across multiple [=epochs=], - each [=epoch=]'s [=impressions=] are evaluated separately. - [=Impressions=] are organized by [=epoch=] based on their [=impression/timestamp=] - relative to the [=conversion site=]. - For each [=epoch=], both the per-[=site=] [=privacy budget=] and [=safety limits=] - are checked and deducted independently using an atomic transaction. - This approach ensures that budget consumption is properly tracked across time periods. - 1. For each |epoch| from |startEpoch| to |currentEpoch|, inclusive: 1. Let |impressions| be the result of invoking [=common matching logic=] with |options|, |topLevelSite|, |intermediarySite|, |epoch|, and |now|. -

The [=common matching logic=] filters [=impressions=] - from the [=impression store=] by comparing each impression's [=impression/timestamp=] - against |topLevelSite| to determine which [=epoch=] it belongs to. - Only [=impressions=] that fall within the current |epoch| are selected. - 1. If |impressions| [=set/is empty|is not empty=]: - 1. Let |impressionSites| be an [=set/is empty|empty=] [=set=]. - - 1. [=set/iterate|For each=] |impression| in |impressions|, - [=set/append=] |impression|'s [=impression/impression site=] to |impressionSites|. - 1. Let |key| be a [=privacy budget key=] whose items are |epoch| and |topLevelSite|. 1. Let |budgetOk| be the result of invoking [=deduct privacy budget=] @@ -1977,24 +1832,12 @@ To do attribution and fill a histogram, given 1. If |budgetOk| is true and |safetyOk| is true, [=set/extend=] |matchedImpressions| with |impressions|. -

If either the per-[=site=] [=privacy budget=] or the [=safety limits=] - for a given [=epoch=] are insufficient, [=impressions=] from that [=epoch=] are excluded - from attribution (by dropping that epoch's data), but [=impressions=] from other [=epochs=] - with sufficient budget may still be included. - This implements the per-epoch atomic transaction pattern from the Big Bird algorithm. + 1. If |matchedImpressions| [=set/is empty=], return the result of invoking [=create an all-zero histogram=] with |options|' [=validated conversion options/histogram size=]. -1. Let |impressionSites| be an [=set/is empty|empty=] [=set=]. - -1. [=set/iterate|For each=] |impression| in |matchedImpressions|, - [=set/append=] |impression|'s [=impression/impression site=] to |impressionSites|. - -

The [=common matching logic=] can select [=impressions=] - from multiple [=impression sites=], - such as to support cross-publisher attribution. 1. Set |histogram| to the result of [=fill a histogram with last-n-touch attribution=] with |matchedImpressions|, |options|' [=validated conversion options/histogram size=], From a8c6b8df9fccf127b4f3d82648a09a16d933f666 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Wed, 12 Nov 2025 14:22:35 +0900 Subject: [PATCH 3/7] update safety limit draft --- api.bs | 2 -- 1 file changed, 2 deletions(-) diff --git a/api.bs b/api.bs index 3dc0dc2..4b3e115 100644 --- a/api.bs +++ b/api.bs @@ -1186,7 +1186,6 @@ is added to the aggregated histogram. - ### Global Privacy Budget Store ### {#s-global-privacy-budget-store} @@ -1802,7 +1801,6 @@ To do attribution and fill a histogram, given 1. If |singleEpoch| is true: 1. Set |matchedImpressions| to the result of invoking [=common matching logic=] with |options|, |topLevelSite|, |intermediarySite|, |currentEpoch|, and |now|. - 1. TODO safety limits in single-epoch case. 1. If |singleEpoch| is false: 1. For each |epoch| from |startEpoch| to |currentEpoch|, inclusive: From f217ef5505f7ed91eff47f043d1ea1a5959f621c Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 4 Dec 2025 11:16:58 -0500 Subject: [PATCH 4/7] add user action checks This adds the checks that need to happen on user action context, following Alg 2 of BigBird; not that it follows the latest version which has conversion check moved within the for loop over epochs. --- api.bs | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 101 insertions(+), 6 deletions(-) diff --git a/api.bs b/api.bs index 4b3e115..1a96d90 100644 --- a/api.bs +++ b/api.bs @@ -1253,19 +1253,44 @@ limiting its ability to rapidly deplete the [=global privacy budget=]. ### User Action Context Store ### {#s-user-action-context-store} The user action context store is a [=map=] keyed by [=user action contexts=] -and containing values that are [=sets=] of [=sites=]. +and containing values that are [=user action context entries=]. A user action context is an identifier for a sequence of API invocations that are associated with a single intentional user action, such as a navigation or click. +A user action context entry is a [=struct=] with the following fields: + +

+: Allowed Impression Sites +:: A [=set=] of [=impression sites=] that have been permitted + to save [=impressions=] within this [=user action context=]. +: Impression Site Counter +:: A non-negative integer representing the remaining number of + new [=impression sites=] that may save [=impressions=] + within this [=user action context=]. + Initialized to an [=implementation-defined=] impression site count cap. +: Allowed Conversion Sites +:: A [=map=] keyed by [=epoch index=] and containing values that are [=sets=] + of [=conversion sites=] that have been permitted to measure [=conversions=] + within this [=user action context=] for that [=epoch=]. +: Conversion Site Counters +:: A [=map=] keyed by [=epoch index=] and containing values that are + non-negative integers representing the remaining number of + new [=conversion sites=] that may measure [=conversions=] + within this [=user action context=] for that [=epoch=]. + Each counter is initialized to an [=implementation-defined=] conversion site count cap. +
+ The [=user action context store=] tracks which [=sites=] have accessed quota [=privacy budgets=] (either [=impression site quota store|impression site quotas=] or [=conversion site quota store|conversion site quotas=]) within the current [=user action context=]. -This enables enforcement of the [=quota count cap=]. +This enables enforcement of site count caps, +limiting the number of distinct sites that can participate +in attribution within a single user action.

A [=user action context=] typically corresponds to a top-level navigation or other substantial user interaction. @@ -1280,8 +1305,14 @@ returning a [=user action context=]: associated with the current execution context, return it. 1. Otherwise, create a new [=user action context=] identifier, - add it to the [=user action context store=] with an empty [=set=] value, - and return it. + create a new [=user action context entry=] with: + * [=user action context entry/Allowed Impression Sites=] set to an empty [=set=], + * [=user action context entry/Impression Site Counter=] set to the [=impression site count cap=], + * [=user action context entry/Allowed Conversion Sites=] set to an empty [=map=], + * [=user action context entry/Conversion Site Counters=] set to an empty [=map=], + + add the identifier and entry to the [=user action context store=], + and return the identifier.

The [=user agent=] determines when [=user action contexts=] expire and are removed from the [=user action context store=]. @@ -1290,6 +1321,67 @@ or when a new top-level navigation occurs. +

+To check impression site allowance, +given an [=impression site=] |impSite| +and a [=user action context=] |uaContext|, +returning a [=boolean=]: + +1. Let |entry| be the result of [=map/get|getting=] |uaContext| + from the [=user action context store=]. + +1. If |entry|'s [=user action context entry/Allowed Impression Sites=] + [=set/contains=] |impSite|, return true. + +1. If |entry|'s [=user action context entry/Impression Site Counter=] is 0, + return false. + +1. [=set/Append=] |impSite| to |entry|'s + [=user action context entry/Allowed Impression Sites=]. + +1. Decrement |entry|'s [=user action context entry/Impression Site Counter=] by 1. + +1. Return true. + +
+ +
+To check conversion site allowance, +given a [=conversion site=] |convSite|, +an [=epoch index=] |epoch|, +and a [=user action context=] |uaContext|, +returning a [=boolean=]: + +1. Let |entry| be the result of [=map/get|getting=] |uaContext| + from the [=user action context store=]. + +1. If |entry|'s [=user action context entry/Conversion Site Counters=] + does not [=map/contain=] |epoch|: + + 1. [=map/Set=] |entry|'s [=user action context entry/Conversion Site Counters=]\[|epoch|] + to the [=conversion site count cap=]. + + 1. [=map/Set=] |entry|'s [=user action context entry/Allowed Conversion Sites=]\[|epoch|] + to an empty [=set=]. + +1. Let |allowedSites| be the result of [=map/get|getting=] |epoch| + from |entry|'s [=user action context entry/Allowed Conversion Sites=]. + +1. If |allowedSites| [=set/contains=] |convSite|, return true. + +1. Let |counter| be the result of [=map/get|getting=] |epoch| + from |entry|'s [=user action context entry/Conversion Site Counters=]. + +1. If |counter| is 0, return false. + +1. [=set/Append=] |convSite| to |allowedSites|. + +1. Decrement |entry|'s [=user action context entry/Conversion Site Counters=]\[|epoch|] by 1. + +1. Return true. + +
+ ### Epoch Start Store ### {#s-epoch-start} @@ -1560,6 +1652,7 @@ The saveImpression(|options|) method steps are 1. otherwise, the result of [=obtain a site|obtaining a site=] from |settings|' [=environment settings object/origin=]. + 1. Let |uaContext| be the [=current user action context=]. 1. Validate the page-supplied API inputs: 1. If |options|.{{AttributionImpressionOptions/histogramIndex}} is greater than or equal to the [=implementation-defined=] [=maximum histogram size=], @@ -1606,8 +1699,10 @@ The saveImpression(|options|) method steps are :: |options|.{{AttributionImpressionOptions/histogramIndex}} : [=impression/Priority=] :: |options|.{{AttributionImpressionOptions/priority}} - 1. If the Attribution API is [[#opt-out|enabled]], - save |impression| to the [=impression store=]. + 1. If the Attribution API is [[#opt-out|enabled]]: + 1. Let |allowed| be the result of [=check impression site allowance|checking impression site allowance=] + given |site| and |uaContext|. + 1. If |allowed| is true, save |impression| to the [=impression store=]. 1. Let |result| be a new {{AttributionImpressionResult}}. 1. Return [=a promise resolved with=] |result| in |realm|. From 333929840147165d0acbb2d7ea256af3ddd5bc92 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 4 Dec 2025 13:41:20 -0500 Subject: [PATCH 5/7] have budget and safety checks together in Algo 2 in Big Bird, safety limit deductions occur if and only if privacy budget also happens. Thus going to put the safety limits into the deduct privacy budget function (renamed as deduct privacy and safety budgets). --- api.bs | 34 +++++++++------------------------- 1 file changed, 9 insertions(+), 25 deletions(-) diff --git a/api.bs b/api.bs index 1a96d90..c47002f 100644 --- a/api.bs +++ b/api.bs @@ -1133,7 +1133,7 @@ A privacy budget key is a [=tuple=] consisting of the following items
-To deduct privacy budget +To deduct privacy and safety budgets given a [=privacy budget key=] |key|, [[WEBIDL#idl-double|double]] |epsilon|, integer |value|, @@ -1905,25 +1905,19 @@ To do attribution and fill a histogram, given 1. If |impressions| [=set/is empty|is not empty=]: + 1. Let |quotaCountOk| be the result of invoking [=check conversion site allowance=] + with |topLevelSite|, |epoch|, and |uaContext|. + 1. Let |key| be a [=privacy budget key=] whose items are |epoch| and |topLevelSite|. - 1. Let |budgetOk| be the result of invoking [=deduct privacy budget=] + 1. Let |budgetAndSafetyOk| be the result of invoking [=deduct privacy and safety budgets=] with |key|, |options|' [=validated conversion options/epsilon=], |options|' [=validated conversion options/value=], |options|'s [=validated conversion options/max value=], and null. - 1. Let |safetyOk| be the result of invoking [=check and deduct safety limit budgets for impression sites=] - with |epoch|, - |impressionSites|, - |topLevelSite|, - |uaContext|, - |options|' [=validated conversion options/epsilon=], - |options|' [=validated conversion options/value=], - |options|'s [=validated conversion options/max value=], - and null. - - 1. If |budgetOk| is true and |safetyOk| is true, [=set/extend=] |matchedImpressions| with |impressions|. + 1. If |quotaCountOk| is true and |budgetAndSafetyOk| is true, + [=set/extend=] |matchedImpressions| with |impressions|. @@ -1944,23 +1938,13 @@ To do attribution and fill a histogram, given 1. Let |key| be a [=privacy budget key=] whose items are |currentEpoch| and |topLevelSite|. - 1. Let |budgetOk| be the result of [=deduct privacy budget=] + 1. Let |budgetAndSafetyOk| be the result of [=deduct privacy and safety budgets=] with |key|, |options|' [=validated conversion options/epsilon=], |options|' [=validated conversion options/value=] |options|'s [=validated conversion options/max value=], and |l1Norm|. - 1. Let |safetyOk| be the result of invoking [=check and deduct safety limit budgets for impression sites=] - with |currentEpoch|, - |impressionSites|, - |topLevelSite|, - |uaContext|, - |options|' [=validated conversion options/epsilon=], - |options|' [=validated conversion options/value=], - |options|'s [=validated conversion options/max value=], - and |l1Norm|. - - 1. If |budgetOk| is false or |safetyOk| is false, set |histogram| to the result of invoking + 1. If |budgetAndSafetyOk| is false, set |histogram| to the result of invoking [=create an all-zero histogram=] with |options|' [=validated conversion options/histogram size=]. 1. Return |histogram|. From 140ae910098ce191701acb6a078a54af628b018e Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 4 Dec 2025 22:28:24 -0500 Subject: [PATCH 6/7] safety limit and privacy deductions iff all can occur. --- api.bs | 119 +++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 111 insertions(+), 8 deletions(-) diff --git a/api.bs b/api.bs index c47002f..40a0dab 100644 --- a/api.bs +++ b/api.bs @@ -1132,14 +1132,46 @@ A privacy budget key is a [=tuple=] consisting of the following items +
+To compute impression site deductions, +given a [=map=] |impressionsByImpSite| from [=impression sites=] to [=sets=] of [=impressions=], +and an integer |totalDeduction|, +returning a [=map=] from [=impression sites=] to integers: + +1. Let |totalImpressions| be 0. + +1. [=map/iterate|For each=] |impSite| → |impressions| in |impressionsByImpSite|: + + 1. Increment |totalImpressions| by the [=set/size=] of |impressions|. + +1. Let |impSiteDeductions| be a new [=map=]. + +1. [=map/iterate|For each=] |impSite| → |impressions| in |impressionsByImpSite|: + + 1. Let |count| be the [=set/size=] of |impressions|. + + 1. Let |siteDeduction| be (|count| / |totalImpressions|) * |totalDeduction|, + rounded towards positive Infinity. + + 1. [=map/Set=] |impSiteDeductions|\[|impSite|] to |siteDeduction|. + +1. Return |impSiteDeductions|. + +
+
To deduct privacy and safety budgets given a [=privacy budget key=] |key|, +a [=map=] |impressionsByImpSite| from [=impression sites=] to [=sets=] of [=impressions=], [[WEBIDL#idl-double|double]] |epsilon|, integer |value|, integer |maxValue|, and nullable integer |l1Norm|: +1. Let |epoch| be the [=epoch index=] component of |key|. + +1. Let |conversionSite| be the [=site=] component of |key|. + 1. If the [=privacy budget store=] does not [=map/contain=] |key|, [=map/set=] its value of |key| to be a [=user agent=]-defined value, plus 1000. @@ -1174,13 +1206,60 @@ is added to the aggregated histogram. 1. Let |deduction| be |deductionFp| * 1000000, rounded towards positive Infinity. -1. If |deduction| is greater than |currentValue|, - [=map/set|set=] the value of |key| in the [=privacy budget store=] to 0 - and return false. +1. Let |impSiteDeductions| be the result of [=compute impression site deductions|computing impression site deductions=] + with |impressionsByImpSite|, |value|, |maxValue|, |epsilon| + +

TODO: The [=compute impression site deductions=] function needs to still be defined. + +1. Check that sufficient budget exists in all relevant stores + before deducting from any of them. + This ensures atomicity: either all deductions succeed, or none occur. + + 1. If |deduction| is greater than |currentValue|, return false. + + 1. If the [=global privacy budget store=] does not [=map/contain=] |epoch|, + [=map/set=] its value to the [=global budget per epoch=]. + + 1. If |deduction| is greater than [=global privacy budget store=]\[|epoch|], + return false. + + 1. Let |convQuotaKey| be a [=conversion site quota key=] + whose items are |epoch| and |conversionSite|. + + 1. If the [=conversion site quota store=] does not [=map/contain=] |convQuotaKey|, + [=map/set=] its value to the [=conversion site quota per epoch=]. -1. [=map/set|Set=] the value of |key| in the [=privacy budget store=] - to |currentValue| − |deduction| - and return true. + 1. If |deduction| is greater than [=conversion site quota store=]\[|convQuotaKey|], + return false. + + 1. [=map/iterate|For each=] |impSite| → |siteDeduction| in |impSiteDeductions|: + + 1. Let |impQuotaKey| be an [=impression site quota key=] + whose items are |epoch| and |impSite|. + + 1. If the [=impression site quota store=] does not [=map/contain=] |impQuotaKey|, + [=map/set=] its value to the [=impression site quota per epoch=]. + + 1. If |siteDeduction| is greater than [=impression site quota store=]\[|impQuotaKey|], + return false. + +1. All budget checks passed; perform the deductions atomically. + + 1. [=map/set|Set=] the value of |key| in the [=privacy budget store=] + to |currentValue| − |deduction|. + + 1. Decrement [=global privacy budget store=]\[|epoch|] by |deduction|. + + 1. Decrement [=conversion site quota store=]\[|convQuotaKey|] by |deduction|. + + 1. [=map/iterate|For each=] |impSite| → |siteDeduction| in |impSiteDeductions|: + + 1. Let |impQuotaKey| be an [=impression site quota key=] + whose items are |epoch| and |impSite|. + + 1. Decrement [=impression site quota store=]\[|impQuotaKey|] by |siteDeduction|. + +1. Return true.

@@ -1908,10 +1987,22 @@ To do attribution and fill a histogram, given 1. Let |quotaCountOk| be the result of invoking [=check conversion site allowance=] with |topLevelSite|, |epoch|, and |uaContext|. + 1. Let |impressionsByImpSite| be a new [=map=]. + + 1. [=set/iterate|For each=] |impression| in |impressions|: + + 1. Let |impSite| be |impression|'s [=impression/impression site=]. + + 1. If |impressionsByImpSite| does not [=map/contain=] |impSite|, + [=map/set=] |impressionsByImpSite|\[|impSite|] to an empty [=set=]. + + 1. [=set/Append=] |impression| to |impressionsByImpSite|\[|impSite|]. + 1. Let |key| be a [=privacy budget key=] whose items are |epoch| and |topLevelSite|. 1. Let |budgetAndSafetyOk| be the result of invoking [=deduct privacy and safety budgets=] - with |key|, |options|' [=validated conversion options/epsilon=], + with |key|, |impressionsByImpSite|, + |options|' [=validated conversion options/epsilon=], |options|' [=validated conversion options/value=], |options|'s [=validated conversion options/max value=], and null. @@ -1936,10 +2027,22 @@ To do attribution and fill a histogram, given 1. [=Assert=]: |l1Norm| is less than or equal to |options|' [=validated conversion options/value=]. + 1. Let |impressionsByImpSite| be a new [=map=]. + + 1. [=set/iterate|For each=] |impression| in |matchedImpressions|: + + 1. Let |impSite| be |impression|'s [=impression/impression site=]. + + 1. If |impressionsByImpSite| does not [=map/contain=] |impSite|, + [=map/set=] |impressionsByImpSite|\[|impSite|] to an empty [=set=]. + + 1. [=set/Append=] |impression| to |impressionsByImpSite|\[|impSite|]. + 1. Let |key| be a [=privacy budget key=] whose items are |currentEpoch| and |topLevelSite|. 1. Let |budgetAndSafetyOk| be the result of [=deduct privacy and safety budgets=] - with |key|, |options|' [=validated conversion options/epsilon=], + with |key|, |impressionsByImpSite|, + |options|' [=validated conversion options/epsilon=], |options|' [=validated conversion options/value=] |options|'s [=validated conversion options/max value=], and |l1Norm|. From 5527b8da47206076ce87ac4de64b67ff698a4b2e Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 4 Dec 2025 22:46:07 -0500 Subject: [PATCH 7/7] clean up --- api.bs | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/api.bs b/api.bs index 40a0dab..cce078c 100644 --- a/api.bs +++ b/api.bs @@ -1132,32 +1132,7 @@ A privacy budget key is a [=tuple=] consisting of the following items -
-To compute impression site deductions, -given a [=map=] |impressionsByImpSite| from [=impression sites=] to [=sets=] of [=impressions=], -and an integer |totalDeduction|, -returning a [=map=] from [=impression sites=] to integers: - -1. Let |totalImpressions| be 0. - -1. [=map/iterate|For each=] |impSite| → |impressions| in |impressionsByImpSite|: - - 1. Increment |totalImpressions| by the [=set/size=] of |impressions|. - -1. Let |impSiteDeductions| be a new [=map=]. - -1. [=map/iterate|For each=] |impSite| → |impressions| in |impressionsByImpSite|: - 1. Let |count| be the [=set/size=] of |impressions|. - - 1. Let |siteDeduction| be (|count| / |totalImpressions|) * |totalDeduction|, - rounded towards positive Infinity. - - 1. [=map/Set=] |impSiteDeductions|\[|impSite|] to |siteDeduction|. - -1. Return |impSiteDeductions|. - -
To deduct privacy and safety budgets