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 01/40] Update api.bs --- api.bs | 360 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 348 insertions(+), 12 deletions(-) diff --git a/api.bs b/api.bs index 3e8b23fd..d0977f2f 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. +
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. + +
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: + +
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. + +
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. + +
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}}:
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 02/40] update safety limit draft --- api.bs | 175 +++------------------------------------------------------ 1 file changed, 9 insertions(+), 166 deletions(-) diff --git a/api.bs b/api.bs index d0977f2f..3dc0dc26 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. -
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.
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 03/40] update safety limit draft --- api.bs | 2 -- 1 file changed, 2 deletions(-) diff --git a/api.bs b/api.bs index 3dc0dc26..4b3e1153 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 04/40] 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 4b3e1153..1a96d90f 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: + +
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. +
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.
Like the [=impression store=], the [=privacy budget store=] and related stores do not use a [=storage key=]. @@ -1205,15 +1199,6 @@ nullable integer |l1Norm|: 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. 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=] @@ -1232,8 +1217,6 @@ nullable integer |l1Norm|: 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=] @@ -1288,158 +1271,42 @@ from enabling excessive budget that could be maliciously triggered. -### Conversion Site Quota Store ### {#s-conversion-site-quota-store} +### Attribution API Activation ### {#s-api-activation} -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=]. +The Attribution API requires user activation to prevent abuse. +A global attribution API flag is associated with each {{Window}} object. +This flag is initially false. -A conversion site quota key is a [=tuple=] consisting of the following items: +The API can be invoked if either: +1. The [=global attribution API flag=] for the current {{Window}} is true, or +2. The current {{Window}} has [=transient activation=] that can be [=consume user activation|consumed=]. -
This approach allows a single user action to enable multiple +API invocations within the same page session, while still requiring +an initial user gesture to activate the API. -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 [=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: - -
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. +
TODO: Define how long the [=global attribution API flag=] remains true +and under what conditions it should be reset (e.g., navigation, page lifecycle events).
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. - -
Typical values might be: TODO @@ -1666,8 +1523,7 @@ 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=]. + [=global privacy budget store=] and [=impression site quota store=]. 1. If |sites| [=set/is empty|is not empty=]: @@ -1723,7 +1579,9 @@ and [=implicit API inputs=] |implicitInputs|: 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. Let |window| be |document|'s [=relevant global object=]. +1. Let |activationOk| be the result of [=check attribution API activation|checking attribution API activation=] + given |window|. 1. Validate the page-supplied API inputs: 1. If |options|.{{AttributionImpressionOptions/histogramIndex}} is greater than or equal to the [=implementation-defined=] [=maximum histogram size=], @@ -1772,10 +1630,8 @@ and [=implicit API inputs=] |implicitInputs|: :: |options|.{{AttributionImpressionOptions/histogramIndex}} : [=impression/Priority=] :: |options|.{{AttributionImpressionOptions/priority}} - 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. If the Attribution API is [[#opt-out|enabled]] and |activationOk| is true: + 1. Save |impression| to the [=impression store=]. 1. Let |result| be a new {{AttributionImpressionResult}}. 1. Return [=a promise resolved with=] |result| in |realm|. @@ -1864,10 +1720,9 @@ and [=implicit API inputs=] |implicitInputs|: 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 |window| be |document|'s [=relevant global object=]. +1. Let |activationOk| be the result of [=check attribution API activation|checking attribution API activation=] + given |window|. 1. Let |validatedOptions| be the result of [=validate AttributionConversionOptions|validating=] |options|, returning [=a promise rejected with=] any thrown reason. @@ -1875,11 +1730,13 @@ and [=implicit API inputs=] |implicitInputs|: 1. Run the following steps [=in parallel=]: 1. Let |report| be the result of invoking [=create an all-zero histogram=] with |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|, + 1. If the Attribution API is [[#opt-out|enabled]] and |activationOk| is true, + set |report| to the result of [=do attribution and fill a histogram=] with + |validatedOptions|, |implicitInputs|' [=implicit API inputs/top-level site=], - |implicitInputs|' [=implicit API inputs/intermediary site=], and - |implicitInputs|' [=implicit API inputs/timestamp=]. + |implicitInputs|' [=implicit API inputs/intermediary site=], + |implicitInputs|' [=implicit API inputs/timestamp=], and + |activationOk|. 1. Let |aggregationService| be |validatedOptions|'s [=validated conversion options/aggregation service=]. 1. Switch on the value of |aggregationService|.{{AttributionAggregationService/protocol}}:
TODO: The [=compute impression site deductions=] function needs to still be defined. + with |impressionsByImpSite| and |deduction|. 1. Check that sufficient budget exists in all relevant stores before deducting from any of them. @@ -1228,6 +1231,29 @@ nullable integer |l1Norm|:
This function currently includes only a basic optimization +that assigns zero deduction to impression sites with no impressions. +Additional optimizations are possible and may be added to this function in the future. + +1. Let |impSiteDeductions| be a new [=map=]. + +1. [=map/iterate|For each=] |impSite| → |impressions| in |impressionsByImpSite|: + + 1. If |impressions| [=set/is empty=]: + 1. [=map/Set=] |impSiteDeductions|\[|impSite|] to 0. + + 1. Otherwise: + 1. [=map/Set=] |impSiteDeductions|\[|impSite|] to |deduction|. + +1. Return |impSiteDeductions|. + +
Typical values might be: -TODO - - ### Last Browsing History Clear Time ### {#last-clear} The last browsing history clear is a [=moment=] @@ -2178,6 +2161,29 @@ it is not [=implementation-defined=]. Deciding on a value for differential privacy parameters is hard and therefore TBD. +### Safety Limits Configuration ### {#safety-limits-configuration} + +[=User agents=] configure [=safety limits=] by defining values +as multiples of the per-[=site=] [=privacy budget=] (εsite): + +* Global budget per epoch (εglobal): + The maximum privacy budget available across all [=sites=] per [=epoch=], + specified in [=microepsilons=]. + Implementations [=must=] set this value to at least 100 times + the per-[=site=] [=privacy budget=] per [=epoch=]. + +* Impression site quota per epoch (εimp-quota): + The maximum privacy budget that a single [=impression site=] + can enable to be consumed from the [=global privacy budget=] per [=epoch=], + specified in [=microepsilons=]. + Implementations [=must=] set this value to at least 10 times + the per-[=site=] [=privacy budget=] per [=epoch=]. + +
Setting [=safety limits=] as multiples of the per-[=site=] budget +ensures that exploiting shared limits requires coordination by many sites, +making them primarily useful as a means of protecting against abuse +rather than as a primary privacy mechanism. + ## User Control and Visibility ## {#user-control} From d0395edd2e1ae3afdd9c77709933541c8d9d0aac Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:18:16 -0500 Subject: [PATCH 11/40] handle cases in compute impression site deductions the simpler version was more than lacking optimizations; it would have under deducted in the single epoch but multiple impressions site case. --- api.bs | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/api.bs b/api.bs index 36c016bf..a8f07733 100644 --- a/api.bs +++ b/api.bs @@ -1150,9 +1150,6 @@ nullable integer |l1Norm|: 1. Let |conversionSite| be the [=site=] component of |key|. -1. If there are no impressions in the current epoch, no privacy loss is deducted from budgets or quotas. - 1. Return true. - 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. @@ -1236,19 +1233,27 @@ To compute impression site deductions given a [=map=] |impressionsByImpSite| from [=impression sites=] to [=sets=] of [=impressions=], and integer |deduction|: -
This function currently includes only a basic optimization -that assigns zero deduction to impression sites with no impressions. -Additional optimizations are possible and may be added to this function in the future. - -1. Let |impSiteDeductions| be a new [=map=]. +1. Let |impSiteDeductions| be a new [=map=]. +1. Let |numberImpressionSites| be the number of impression sites in |impressionsByImpSite|. 1. [=map/iterate|For each=] |impSite| → |impressions| in |impressionsByImpSite|: 1. If |impressions| [=set/is empty=]: 1. [=map/Set=] |impSiteDeductions|\[|impSite|] to 0. - 1. Otherwise: + 1. else if |singleEpoch| is true and |numImpressionSites| is 1: 1. [=map/Set=] |impSiteDeductions|\[|impSite|] to |deduction|. + 1. else +
In this case we need to use the global sensitivity which is the value of |deduction| only in the multi-epoch case; + so we need to recompute it case we are in the single epoch but multiple impression sites case. + 1. Let |sensitivity| be 2 * |value| otherwise. + + 1. Let |noiseScale| be 2 * |maxValue| / |epsilon|. + + 1. Let |deductionFp| be |sensitivity| / |noiseScale|. + 1. Let |deduction| be |deductionFp| * 1000000, rounded towards positive Infinity. + + 1. [=map/Set=] |impSiteDeductions|\[|impSite|] to |deduction| 1. Return |impSiteDeductions|. From 84fce818a278e9c417f26d97c46acc076366df57 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Mon, 9 Feb 2026 11:50:48 -0500 Subject: [PATCH 12/40] pass parameters --- api.bs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/api.bs b/api.bs index a8f07733..5142a786 100644 --- a/api.bs +++ b/api.bs @@ -1185,7 +1185,7 @@ nullable integer |l1Norm|: 1. Let |deduction| be |deductionFp| * 1000000, rounded towards positive Infinity. 1. Let |impSiteDeductions| be the result of [=compute impression site deductions|computing impression site deductions=] - with |impressionsByImpSite| and |deduction|. + with |impressionsByImpSite|, |deduction|, |value|, |l1Norm| 1. Check that sufficient budget exists in all relevant stores before deducting from any of them. @@ -1234,14 +1234,13 @@ given a [=map=] |impressionsByImpSite| from [=impression sites=] to [=sets=] of and integer |deduction|: 1. Let |impSiteDeductions| be a new [=map=]. -1. Let |numberImpressionSites| be the number of impression sites in |impressionsByImpSite|. -1. [=map/iterate|For each=] |impSite| → |impressions| in |impressionsByImpSite|: +1. Let |numberImpressionSites| be the number of impression sites in |impressionsByImpSite|. - 1. If |impressions| [=set/is empty=]: - 1. [=map/Set=] |impSiteDeductions|\[|impSite|] to 0. +1. Let |singleEpoch| be true if |l1Norm| is non-null, false otherwise. - 1. else if |singleEpoch| is true and |numImpressionSites| is 1: +1. [=map/iterate|For each=] |impSite| → |impressions| in |impressionsByImpSite|: + 1. If |singleEpoch| is true and |numImpressionSites| is 1: 1. [=map/Set=] |impSiteDeductions|\[|impSite|] to |deduction|. 1. else
In this case we need to use the global sensitivity which is the value of |deduction| only in the multi-epoch case; @@ -1251,6 +1250,7 @@ and integer |deduction|: 1. Let |noiseScale| be 2 * |maxValue| / |epsilon|. 1. Let |deductionFp| be |sensitivity| / |noiseScale|. + 1. Let |deduction| be |deductionFp| * 1000000, rounded towards positive Infinity. 1. [=map/Set=] |impSiteDeductions|\[|impSite|] to |deduction| From 55634d3431a9c03f58883139278a952a33b3e04d Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:13:28 -0500 Subject: [PATCH 13/40] update function signature --- api.bs | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/api.bs b/api.bs index 5142a786..1872b27a 100644 --- a/api.bs +++ b/api.bs @@ -1185,7 +1185,7 @@ nullable integer |l1Norm|: 1. Let |deduction| be |deductionFp| * 1000000, rounded towards positive Infinity. 1. Let |impSiteDeductions| be the result of [=compute impression site deductions|computing impression site deductions=] - with |impressionsByImpSite|, |deduction|, |value|, |l1Norm| + with |impressionsByImpSite|, |deduction|, |value|, |maxValue|, |epsilon|, and |l1Norm|. 1. Check that sufficient budget exists in all relevant stores before deducting from any of them. @@ -1231,29 +1231,37 @@ nullable integer |l1Norm|:
In this case we need to use the global sensitivity which is the value of |deduction| only in the multi-epoch case; - so we need to recompute it case we are in the single epoch but multiple impression sites case. - 1. Let |sensitivity| be 2 * |value| otherwise. + + 1. Otherwise: + +
In this case we need to use the global sensitivity which is the value of |deduction| only in the multi-epoch case; + so we need to recompute it in case we are in the single epoch but multiple impression sites case. + + 1. Let |sensitivity| be 2 * |value|. 1. Let |noiseScale| be 2 * |maxValue| / |epsilon|. 1. Let |deductionFp| be |sensitivity| / |noiseScale|. - 1. Let |deduction| be |deductionFp| * 1000000, rounded towards positive Infinity. + 1. Let |siteDeduction| be |deductionFp| * 1000000, rounded towards positive Infinity. - 1. [=map/Set=] |impSiteDeductions|\[|impSite|] to |deduction| + 1. [=map/Set=] |impSiteDeductions|\[|impSite|] to |siteDeduction|. 1. Return |impSiteDeductions|. From 61ff7e7d89887d541ad215b6f10647a349b7967c Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:24:01 -0500 Subject: [PATCH 14/40] fix some bikeshed links --- api.bs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/api.bs b/api.bs index 1872b27a..a21d4b0e 100644 --- a/api.bs +++ b/api.bs @@ -1080,7 +1080,6 @@ 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=]. @@ -1097,6 +1096,10 @@ that are used to manage the expenditure of [=privacy budgets=]: * The [=impression site quota store=] records the state of per-[=impression site=] and per-[=epoch=] quota [=privacy budgets=]. +* The [=privacy budget store=], [=global privacy budget store=], + and [=impression site quota store=] are updated by [=deduct privacy and safety budgets=]. + +
Like the [=impression store=], the [=privacy budget store=] and related stores do not use a [=storage key=]. @@ -2187,7 +2190,7 @@ as multiples of the per-[=site=] [=privacy budget=] (εsite): * Impression site quota per epoch (εimp-quota): The maximum privacy budget that a single [=impression site=] - can enable to be consumed from the [=global privacy budget=] per [=epoch=], + can enable to be consumed from the [=global privacy budget store|global privacy budget=] per [=epoch=], specified in [=microepsilons=]. Implementations [=must=] set this value to at least 10 times the per-[=site=] [=privacy budget=] per [=epoch=]. @@ -3114,7 +3117,7 @@ If the [=privacy budget=] for that [=epoch=] is not sufficient, the impressions from that [=epoch=] are not used. Each time a [=conversion site=] invokes measureConversion() -the [=deduct privacy budget|privacy budget is deducted=] +the [=deduct privacy and safety budgets|privacy budget is deducted=] for [=epochs=] from which the [=attribution logic=] selected [=impressions=].
The addition of 1000 to this value - ensures that the rounding errors added by this algorithm do not cause - the budget to be exceeded unnecessarily after multiple invocations. - The privacy loss from the additional one-thousandth of an epsilon is trivial. - -1. Let |currentValue| be the result of [=map/get|getting the value=] of |key| - in the [=privacy budget store=]. - 1. Let |sensitivity| be |l1Norm| if |l1Norm| is non-null, 2 * |value| otherwise. 1. Let |noiseScale| be 2 * |maxValue| / |epsilon|. @@ -1185,13 +1172,19 @@ nullable integer |l1Norm|: 1. Let |deduction| be |deductionFp| * 1000000, rounded towards positive Infinity. -1. Let |impSiteDeductions| be the result of [=compute impression site deductions|computing impression site deductions=] - with |impressionsByImpSite|, |deduction|, |value|, |maxValue|, |epsilon|, and |l1Norm|. +1. Let |impressionSiteDeductions| be the result of [=compute impression site deductions|computing impression site deductions=] + with |impressionsBySite|, |deduction|, |value|, |maxValue|, |epsilon|, and |l1Norm|. 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. +
TODO: This section needs additional work to specify + how locking is performed to ensure atomicity across multiple stores. + + 1. Let |currentValue| be the result of [=map/get|getting the value=] of |key| + in the [=privacy budget store=]. + 1. If |deduction| is greater than |currentValue|, return false. 1. If the [=global privacy budget store=] does not [=map/contain=] |epoch|, @@ -1200,28 +1193,37 @@ nullable integer |l1Norm|: 1. If |deduction| is greater than [=global privacy budget store=]\[|epoch|], return false. - 1. [=map/iterate|For each=] |impSite| → |siteDeduction| in |impSiteDeductions|: + 1. [=map/iterate|For each=] |impressionSite| → |siteDeduction| in |impressionSiteDeductions|: 1. Let |impQuotaKey| be an [=impression site quota key=] - whose items are |epoch| and |impSite|. + whose items are |epoch| and |impressionSite|. - 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 the [=impression site quota store=] does not [=map/contain=] |impQuotaKey|, compare |siteDeduction| to the value of + [=impression site quota per epoch=] and if it is greater, return false. 1. If |siteDeduction| is greater than [=impression site quota store=]\[|impQuotaKey|], return false. -1. All budget checks passed; perform the deductions atomically. +1. All budget checks passed, so perform the deductions atomically: + + 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. + +
The addition of 1000 to this value + ensures that the rounding errors added by this algorithm do not cause + the budget to be exceeded unnecessarily after multiple invocations. + The privacy loss from the additional one-thousandth of an epsilon is trivial. 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. [=map/iterate|For each=] |impSite| → |siteDeduction| in |impSiteDeductions|: + 1. [=map/iterate|For each=] |impressionSite| → |siteDeduction| in |impressionSiteDeductions|: 1. Let |impQuotaKey| be an [=impression site quota key=] - whose items are |epoch| and |impSite|. + whose items are |epoch| and |impressionSite|. 1. Decrement [=impression site quota store=]\[|impQuotaKey|] by |siteDeduction|. @@ -1231,23 +1233,26 @@ nullable integer |l1Norm|:
TODO: Additional work to specify +how locking is performed to ensure atomicity across checks and deductions, but lock should start about here. -
TODO: This section needs additional work to specify - how locking is performed to ensure atomicity across multiple stores. +1. Let |budgetAvailable| be the result of [=check for available privacy budget|checking for available privacy budget=] + with |key|, |epoch|, |deduction|, and |impressionSiteDeductions|. + +1. If |budgetAvailable| is false, return false. + +1. All budget checks passed, so perform the deductions atomically: 1. Let |currentValue| be the result of [=map/get|getting the value=] of |key| in the [=privacy budget store=]. - 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. [=map/set|Set=] the value of |key| in the [=privacy budget store=] + to |currentValue| − |deduction|. - 1. If |deduction| is greater than [=global privacy budget store=]\[|epoch|], - return false. + 1. Decrement [=global privacy budget store=]\[|epoch|] by |deduction|. 1. [=map/iterate|For each=] |impressionSite| → |siteDeduction| in |impressionSiteDeductions|: - 1. Let |impQuotaKey| be an [=impression site quota key=] + 1. Let |impressionQuotaKey| be an [=impression site quota key=] whose items are |epoch| and |impressionSite|. - 1. If the [=impression site quota store=] does not [=map/contain=] |impQuotaKey|, compare |siteDeduction| to the value of - [=impression site quota per epoch=] and if it is greater, return false. + 1. Decrement [=impression site quota store=]\[|impressionQuotaKey|] by |siteDeduction|. + +1. Return true. - 1. If |siteDeduction| is greater than [=impression site quota store=]\[|impQuotaKey|], - return false. +
The addition of 1000 to this value ensures that the rounding errors added by this algorithm do not cause the budget to be exceeded unnecessarily after multiple invocations. The privacy loss from the additional one-thousandth of an epsilon is trivial. - 1. [=map/set|Set=] the value of |key| in the [=privacy budget store=] - to |currentValue| − |deduction|. +1. Let |currentValue| be the result of [=map/get|getting the value=] of |key| + in the [=privacy budget store=]. - 1. Decrement [=global privacy budget store=]\[|epoch|] by |deduction|. +1. If |deduction| is greater than |currentValue|, return false. - 1. [=map/iterate|For each=] |impressionSite| → |siteDeduction| in |impressionSiteDeductions|: +1. If the [=global privacy budget store=] does not [=map/contain=] |epoch|, + [=map/set=] its value to the [=global budget per epoch=]. - 1. Let |impQuotaKey| be an [=impression site quota key=] - whose items are |epoch| and |impressionSite|. +1. If |deduction| is greater than [=global privacy budget store=]\[|epoch|], + return false. + +1. [=map/iterate|For each=] |impressionSite| → |siteDeduction| in |impressionSiteDeductions|: - 1. Decrement [=impression site quota store=]\[|impQuotaKey|] by |siteDeduction|. + 1. Let |impressionQuotaKey| be an [=impression site quota key=] + whose items are |epoch| and |impressionSite|. + + 1. If the [=impression site quota store=] does not [=map/contain=] |impressionQuotaKey|, + compare |siteDeduction| to the value of [=impression site quota per epoch=] + and if it is greater, return false. + + 1. If |siteDeduction| is greater than [=impression site quota store=]\[|impressionQuotaKey|], + return false. 1. Return true. From 9970c90666c3a4e344b497a21beed381ec540b0e Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Tue, 10 Feb 2026 12:59:03 -0500 Subject: [PATCH 21/40] address PR feedback --- api.bs | 52 ++++++++++++++++++++++++++-------------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/api.bs b/api.bs index 7948311e..86f8c877 100644 --- a/api.bs +++ b/api.bs @@ -1172,6 +1172,17 @@ nullable integer |l1Norm|: 1. Let |deduction| be |deductionFp| * 1000000, rounded towards positive Infinity. +1. Let |impressionsBySite| be a new [=map=]. + +1. [=set/iterate|For each=] |impression| in |impressions|: + + 1. Let |impressionSite| be |impression|'s [=impression/impression site=]. + + 1. If |impressionsBySite| does not [=map/contain=] |impressionSite|, + [=map/set=] |impressionsBySite|\[|impressionSite|] to an empty [=set=]. + + 1. [=set/Append=] |impression| to |impressionsBySite|\[|impressionSite|]. + 1. Let |impressionSiteDeductions| be the result of [=compute impression site deductions|computing impression site deductions=] with |impressionsBySite|, |deduction|, |value|, |maxValue|, |epsilon|, and |l1Norm|. @@ -1336,15 +1347,13 @@ that could be maliciously triggered. The Attribution API requires user activation to prevent abuse. A global attribution API flag is associated with each {{Window}} object. +The [=global attribution API flag=] is enabled whenever the API is successfully invoked. This flag is initially false. The API can be invoked if either: 1. The [=global attribution API flag=] for the current {{Window}} is true, or 2. The current {{Window}} has [=transient activation=] that can be [=consume user activation|consumed=]. -When the API is successfully invoked: -1. If [=transient activation=] was consumed, set the [=global attribution API flag=] to true. -
This approach allows a single user action to enable multiple API invocations within the same page session, while still requiring an initial user gesture to activate the API. @@ -1566,7 +1575,7 @@ and a [=moment=] |now|: 1. [=map/clear|Clear=] the [=epoch start store=]. -
TODO: Define how to clear [=safety limits=] stores: +
TODO (issue https://github.com/w3c/attribution/issues/367): Define how to clear [=safety limits=] stores: [=global privacy budget store=] and [=impression site quota store=]. 1. If |sites| [=set/is empty|is not empty=]: @@ -1916,21 +1925,10 @@ To do attribution and fill a histogram, given 1. If |impressions| [=set/is empty|is not empty=]: - 1. Let |impressionsBySite| be a new [=map=]. - - 1. [=set/iterate|For each=] |impression| in |impressions|: - - 1. Let |impressionSite| be |impression|'s [=impression/impression site=]. - - 1. If |impressionsBySite| does not [=map/contain=] |impressionSite|, - [=map/set=] |impressionsBySite|\[|impressionSite|] to an empty [=set=]. - - 1. [=set/Append=] |impression| to |impressionsBySite|\[|impressionSite|]. - 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|, |epoch|, |impressionsBySite|, + with |key|, |epoch|, |impressions|, |options|' [=validated conversion options/epsilon=], |options|' [=validated conversion options/value=], |options|'s [=validated conversion options/max value=], @@ -2171,25 +2169,27 @@ will be a fixed value that is determined by the choice of technology used by the [=aggregation service=]; it is not [=implementation-defined=]. -Deciding on a value for differential privacy parameters +### Privacy and Safety Limit Parameter Configuration ### {#safety-limits-configuration} + +
Deciding on a value for differential privacy parameters is hard and therefore TBD. -### Safety Limits Configuration ### {#safety-limits-configuration} +[=User agents=] configure the [=privacy budget=] and [=safety limits=] by defining values +for the following: per-[=site=] [=privacy budget=] (εsite): -[=User agents=] configure [=safety limits=] by defining values -as multiples of the per-[=site=] [=privacy budget=] (εsite): +* The per-[=site=] [=privacy budget=] (εsite) within the range of: TBD -* Global budget per epoch (εglobal): - The maximum privacy budget available across all [=sites=] per [=epoch=], +* The global budget per epoch (εglobal) which is + the maximum privacy budget available across all [=sites=] per [=epoch=], specified in [=microepsilons=]. - Implementations [=must=] set this value to at least 100 times + Implementations [=must=] set this value as a multiple of the per-[=site=] [=privacy budget=] per [=epoch=]. -* Impression site quota per epoch (εimp-quota): - The maximum privacy budget that a single [=impression site=] +* The impression site quota per epoch (εimp-quota) which is + the maximum privacy budget that a single [=impression site=] can enable to be consumed from the [=global privacy budget store|global privacy budget=] per [=epoch=], specified in [=microepsilons=]. - Implementations [=must=] set this value to at least 10 times + Implementations [=must=] set this value as a multiple of the per-[=site=] [=privacy budget=] per [=epoch=].
Setting [=safety limits=] as multiples of the per-[=site=] budget From 3a0f73c77d2c0909f2a93f9aa646498036d5656b Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:09:50 -0500 Subject: [PATCH 22/40] fix bikeshed warnings --- api.bs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.bs b/api.bs index 86f8c877..7b38b42c 100644 --- a/api.bs +++ b/api.bs @@ -1142,7 +1142,7 @@ A privacy budget key is a [=tuple=] consisting of the following items To deduct privacy and safety budgets given a [=privacy budget key=] |key|, [=epoch index=] |epoch|, -a [=map=] |impressionsBySite| from [=impression sites=] to [=sets=] of [=impressions=], +|impressions| as a set of [=impressions=], [[WEBIDL#idl-double|double]] |epsilon|, integer |value|, integer |maxValue|, and From cc0f86aacf568820ca6d8425e881c6a9ecef52e0 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Tue, 10 Feb 2026 16:41:04 -0500 Subject: [PATCH 23/40] incorporate feedback incorporate @apasel422 's feedback --- api.bs | 45 ++++++++++++++++----------------------------- 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/api.bs b/api.bs index 7b38b42c..dab90959 100644 --- a/api.bs +++ b/api.bs @@ -1141,8 +1141,7 @@ A privacy budget key is a [=tuple=] consisting of the following items
TODO: Additional work to specify how locking is performed to ensure atomicity across checks and deductions, but lock should start about here. @@ -1218,7 +1219,6 @@ how locking is performed to ensure atomicity across checks and deductions, but l
In this case we need to use the global sensitivity which is the value of |deduction| only in the multi-epoch case; so we need to recompute it in case we are in the single epoch but multiple impression sites case. - 1. Let |sensitivity| be 2 * |value|. - - 1. Let |noiseScale| be 2 * |maxValue| / |epsilon|. - - 1. Let |deductionFp| be |sensitivity| / |noiseScale|. - - 1. Let |siteDeduction| be |deductionFp| * 1000000, rounded towards positive Infinity. - - 1. [=map/Set=] |impressionSiteDeductions|\[|impressionSite|] to |siteDeduction|. + 1. [=map/Set=] |impressionSiteDeductions|\[|impressionSite|] to |globalDeduction|. 1. Return |impressionSiteDeductions|. @@ -1954,21 +1952,10 @@ To do attribution and fill a histogram, given 1. [=Assert=]: |l1Norm| is less than or equal to |options|' [=validated conversion options/value=]. - 1. Let |impressionsBySite| be a new [=map=]. - - 1. [=set/iterate|For each=] |impression| in |matchedImpressions|: - - 1. Let |impressionSite| be |impression|'s [=impression/impression site=]. - - 1. If |impressionsBySite| does not [=map/contain=] |impressionSite|, - [=map/set=] |impressionsBySite|\[|impressionSite|] to an empty [=set=]. - - 1. [=set/Append=] |impression| to |impressionsBySite|\[|impressionSite|]. - 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|, |currentEpoch|, |impressionsBySite|, + 1. Let |budgetAndSafetyOk| be the result of invoking [=deduct privacy and safety budgets=] + with |key|, |currentEpoch|, |impressions|, |options|' [=validated conversion options/epsilon=], |options|' [=validated conversion options/value=] |options|'s [=validated conversion options/max value=], From 51f2d25a2deb5ffc8ca8279480164cfa1f628cdf Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Wed, 11 Feb 2026 22:55:23 -0500 Subject: [PATCH 24/40] change `check attribution API activation` to throw --- api.bs | 34 ++++++++++++++++------------------ 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/api.bs b/api.bs index dab90959..8dfa3a5d 100644 --- a/api.bs +++ b/api.bs @@ -1362,9 +1362,9 @@ and under what conditions it should be reset (e.g., navigation, page lifecycle e
This approach allows a single user action to enable multiple -API invocations within the same page session, while still requiring +API invocations within the same session, while still requiring an initial user gesture to activate the API.
TODO: Define how long the [=global attribution API flag=] remains true @@ -1378,7 +1381,6 @@ throwing a {{"NotAllowedError"}} {{DOMException}} on failure:
TODO: Additional work to specify how locking is performed to ensure atomicity across checks and deductions, but lock should start about here. @@ -1264,7 +1263,7 @@ a [=map=] |impressionSiteDeductions| from [=impression sites=] to integers:
TODO: Additional work to specify how locking is performed to ensure atomicity across checks and deductions, but lock should start about here. @@ -1263,7 +1261,7 @@ a [=map=] |impressionSiteDeductions| from [=impression sites=] to integers:
TODO: Additional work to specify how locking is performed to ensure atomicity across checks and deductions, but lock should start about here. -1. Let |budgetAvailable| be the result of [=check for available privacy budget|checking for available privacy budget=] - with |key|, |deduction|, and |impressionSiteDeductions|. - -1. If |budgetAvailable| is false, return false. +1. If the result of invoking [=check for available privacy budget|checking for available privacy budget=] + with |key|, |deduction|, and |impressionSiteDeductions| + is false, return false. 1. All budget checks passed, so perform the deductions atomically: @@ -1202,7 +1201,7 @@ how locking is performed to ensure atomicity across checks and deductions, but l 1. Decrement [=global privacy budget store=]\[|epoch|] by |deduction|. - 1. [=map/iterate|For each=] |impressionSite| → |siteDeduction| in |impressionSiteDeductions|: + 1. [=map/iterate|For each=] |impressionSite| in |impressionSites|: 1. Let |impressionQuotaKey| be an [=impression site quota key=] whose items are |epoch| and |impressionSite|. @@ -1210,7 +1209,7 @@ how locking is performed to ensure atomicity across checks and deductions, but l 1. If the [=impression site quota store=] does not [=map/contain=] |impressionQuotaKey|, [=map/set=] its value to the [=impression site quota per epoch=]. - 1. Decrement [=impression site quota store=]\[|impressionQuotaKey|] by |siteDeduction|. + 1. Decrement [=impression site quota store=]\[|impressionQuotaKey|] by |impressionSiteDeduction|. 1. Return true. @@ -1220,31 +1219,25 @@ how locking is performed to ensure atomicity across checks and deductions, but l To check for available privacy budget given a [=privacy budget key=] |key|, integer |deduction|, and -a [=map=] |impressionSiteDeductions| from [=impression sites=] to integers: - -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. - -
The addition of 1000 to this value - ensures that the rounding errors added by this algorithm do not cause - the budget to be exceeded unnecessarily after multiple invocations. - The privacy loss from the additional one-thousandth of an epsilon is trivial. +an integer |impressionSiteDeduction| +and a set |impressionSites| of [=impression sites=],: -1. Let |currentValue| be the result of [=map/get|getting the value=] of |key| - in the [=privacy budget store=]. +1. Let |currentValue| be set to the value of [=map/get|getting the value=] of |key| + from [=privacy budget store=], or the [=per-site privacy budget=] + if [=map/contain|no value has been set=]. 1. If |deduction| is greater than |currentValue|, return false. 1. Let |epoch| be the [=epoch index=] component of |key|. -1. If the [=global privacy budget store=] does not [=map/contain=] |epoch|, - [=map/set=] its value to the [=global budget per epoch=]. +1. Let |currentGlobalValue| be set to the value of [=map/get|getting the value=] + of |epoch| from the [=global privacy budget store=], or the [=global privacy budget + per epoch=] if [=map/contain|no value has been set=]. -1. If |deduction| is greater than [=global privacy budget store=]\[|epoch|], +1. If |deduction| is greater than |currentGlobalValue|, return false. -1. [=map/iterate|For each=] |impressionSite| → |siteDeduction| in |impressionSiteDeductions|: +1. [=map/iterate|For each=] |impressionSite| in |impressionSites|: 1. Let |impressionQuotaKey| be an [=impression site quota key=] whose items are |epoch| and |impressionSite|. @@ -1252,8 +1245,8 @@ a [=map=] |impressionSiteDeductions| from [=impression sites=] to integers: 1. If the [=impression site quota store=] does not [=map/contain=] |impressionQuotaKey| and |siteDeduction| is greater than the [=impression site quota per epoch=], return false. - 1. If [=map/contains=] |impressionQuotaKey| and |siteDeduction| is greater than [=impression site quota store=]\[|impressionQuotaKey|], - return false. + 1. If [=map/contains=] |impressionQuotaKey| and |impressionSiteDeduction| is greater than [=impression site quota store=]\[|impressionQuotaKey|], + return false. 1. Return true. @@ -1268,8 +1261,6 @@ integer |maxValue|, [[WEBIDL#idl-double|double]] |epsilon|, and boolean |isSingleEpoch|: -1. Let |impressionSiteDeductions| be a new [=map=]. - 1. Let |sensitivity| be 2 * |value|. 1. Let |noiseScale| be 2 * |maxValue| / |epsilon|. @@ -1284,18 +1275,14 @@ boolean |isSingleEpoch|: 1. If |isSingleEpoch| is true and |numberImpressionSites| is 1: - 1. [=map/Set=] |impressionSiteDeductions|\[|impressionSite|] to |deduction|. - - 1. Return |impressionSiteDeductions|. + 1. Return |deduction|. 1. Otherwise:
In this case we need to use the global sensitivity which is the value of |deduction| only in the multi-epoch case; so we need to recompute it in case we are in the single epoch but multiple impression sites case. - 1. [=map/Set=] |impressionSiteDeductions|\[|impressionSite|] to |globalDeduction|. - -1. Return |impressionSiteDeductions|. + 1. Return |globalDeduction|.
This approach allows a single user action to enable multiple -API invocations within the same session, while still requiring -an initial user gesture to activate the API. - -
TODO: Define how long the [=global attribution API flag=] remains true -and under what conditions it should be reset (e.g., navigation, page lifecycle events). +
This approach allows a single user action +to enable multiple API invocations within the same session, +while only making the API available to one site +per [=activation triggering input event|activation=].
Deciding on a value for differential privacy parameters is hard and therefore TBD. -[=User agents=] configure the [=privacy budget=] and [=safety limits=] by defining values -for the following: per-[=site=] [=privacy budget=] (εsite): +Privacy Parameters: [=User agents=] configure the [=privacy budget=] and [=safety limits=] by defining values +for the following: * The per-[=site=] [=privacy budget=] (εsite) within the range of: TBD From 3af4cf43f2af9531170f125102a844ec319451ca Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:40:23 -0500 Subject: [PATCH 34/40] fix checks and links --- api.bs | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/api.bs b/api.bs index 5cd9a372..6c0c9c83 100644 --- a/api.bs +++ b/api.bs @@ -1188,7 +1188,7 @@ nullable integer |l1Norm|: how locking is performed to ensure atomicity across checks and deductions, but lock should start about here. 1. If the result of invoking [=check for available privacy budget|checking for available privacy budget=] - with |key|, |deduction|, and |impressionSiteDeductions| + with |key|, |deduction|, and |impressionSiteDeduction| is false, return false. 1. All budget checks passed, so perform the deductions atomically: @@ -1243,7 +1243,7 @@ and a set |impressionSites| of [=impression sites=],: whose items are |epoch| and |impressionSite|. 1. If the [=impression site quota store=] does not [=map/contain=] |impressionQuotaKey| - and |siteDeduction| is greater than the [=impression site quota per epoch=], return false. + and |impressionSiteDeduction| is greater than the [=impression site quota per epoch=], return false. 1. If [=map/contains=] |impressionQuotaKey| and |impressionSiteDeduction| is greater than [=impression site quota store=]\[|impressionQuotaKey|], return false. @@ -1271,18 +1271,16 @@ boolean |isSingleEpoch|: 1. Let |numberImpressionSites| be the [=set/size|number of impression sites=] in |impressionSites|. -1. [=map/iterate|For each=] |impressionSite| in |impressionSites|: - - 1. If |isSingleEpoch| is true and |numberImpressionSites| is 1: +1. If |isSingleEpoch| is true and |numberImpressionSites| is 1: - 1. Return |deduction|. + 1. Return |deduction|. - 1. Otherwise: +1. Otherwise: -
In this case we need to use the global sensitivity which is the value of |deduction| only in the multi-epoch case; +
In this case we need to use the global sensitivity which is the value of |deduction| only in the multi-epoch case; so we need to recompute it in case we are in the single epoch but multiple impression sites case. - 1. Return |globalDeduction|. + 1. Return |globalDeduction|.
Deciding on a value for differential privacy parameters is hard and therefore TBD. -Privacy Parameters: [=User agents=] configure the [=privacy budget=] and [=safety limits=] by defining values +[=User agents=] configure the [=privacy budget=] and [=safety limits=] by defining values for the following: * The per-site privacy budget (εsite) within the range of: TBD From a4d2d2c9d9a6e3bff8cb6cbd2161cc1fc933fcdd Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 19 Feb 2026 20:40:47 -0500 Subject: [PATCH 36/40] address feedback --- api.bs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/api.bs b/api.bs index 8dfb8ec6..45d157e3 100644 --- a/api.bs +++ b/api.bs @@ -1188,11 +1188,14 @@ nullable integer |l1Norm|: how locking is performed to ensure atomicity across checks and deductions, but lock should start about here. 1. If the result of invoking [=check for available privacy budget|checking for available privacy budget=] - with |key|, |deduction|, and |impressionSiteDeduction| + with |key|, |deduction|, |impressionSites|, and |impressionSiteDeduction| is false, return false. 1. All budget checks passed, so perform the deductions atomically: + 1. If the [=privacy budget store=] does not [=map/contain=] |key|, + [=map/set=] its value to the [=per-site privacy budget=]. + 1. Let |currentValue| be the result of [=map/get|getting the value=] of |key| in the [=privacy budget store=]. From 699b2f3299f8a7e331bebb338178b5c6291de6c6 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 26 Feb 2026 11:10:05 -0500 Subject: [PATCH 37/40] refactor for epoch cadences big refactor to implement Option 3 in this issue https://github.com/w3c/attribution/issues/386 --- api.bs | 204 +++++++++++++++++++++++++-------------------------------- 1 file changed, 91 insertions(+), 113 deletions(-) diff --git a/api.bs b/api.bs index 45d157e3..6fede443 100644 --- a/api.bs +++ b/api.bs @@ -1144,20 +1144,23 @@ given a [=privacy budget key=] |key|, [=set=] of [=impressions=] |impressions|, [[WEBIDL#idl-double|double]] |epsilon|, integer |value|, -integer |maxValue|, and -nullable integer |l1Norm|: +integer |maxValue|, +boolean |isSingleEpoch| and integer |l1Norm|: 1. Let |epoch| be the [=epoch index=] component of |key|. -1. Let |sensitivity| be |l1Norm| if |l1Norm| is non-null, 2 * |value| otherwise. +1. Let |l1NormSensitivity| be |l1Norm| if |isSingleEpoch|, 2 * |value| otherwise. + +1. Let |valueSensitivity| be 2 * |value|. 1. Let |noiseScale| be 2 * |maxValue| / |epsilon|. -1. Let |deductionFp| be |sensitivity| / |noiseScale|. +1. Let |l1NormDeductionFp| be |l1NormSensitivity| / |noiseScale|. +1. Let |valueDeductionFp| be |valueSensitivity| / |noiseScale|. -
Single epoch attributions — - the only case that |l1Norm| is non-null — + +
Single epoch attributions do not cause any cascading effects across epochs. Attribution that involves multiple epochs consumes double the budget because of the potential for one change to affect attribution across epochs. @@ -1165,30 +1168,19 @@ nullable integer |l1Norm|: proportional to |maxValue| / |epsilon| is added to the aggregated histogram. -1. If |deductionFp| is negative or greater than [=maximum epsilon=], - [=map/set|set=] the value of |key| in the [=privacy budget store=] to 0 - and return false. - -1. Let |deduction| be |deductionFp| * 1000000, rounded towards positive Infinity. - -1. Let |impressionSites| be a new [=set=]. - -1. [=set/iterate|For each=] |impression| in |impressions|: - - 1. Let |impressionSite| be |impression|'s [=impression/impression site=]. - - 1. [=set/append=] |impressionSite| to |impressionSites|. +1. Let |l1Normdeduction| be |l1NormDeductionFp| * 1000000, rounded towards positive Infinity. -1. Let |isSingleEpoch| be true if |l1Norm| is non-null, false otherwise. +1. Let |valueDeduction| be |valueDeductionFp| * 1000000, rounded towards positive Infinity. -1. Let |impressionSiteDeduction| be the result of [=compute impression site deductions|computing impression site deductions=] - with |impressionSites|, |deduction|, |value|, |maxValue|, |epsilon|, and |isSingleEpoch|. +
One epoch (which is the epoch of the conversion site) may overlap with multiple global epochs used by the global budget + and cross-site quotas. We will look at each impression individually and map it to the global epoch it is in to deduct from the global budget + and impression site quota if those have not already deducted from in this epoch.
TODO: Additional work to specify how locking is performed to ensure atomicity across checks and deductions, but lock should start about here. 1. If the result of invoking [=check for available privacy budget|checking for available privacy budget=] - with |key|, |deduction|, |impressionSites|, and |impressionSiteDeduction| + with |key|, |l1Normdeduction|, |valueDeduction|, |impressions|, is false, return false. 1. All budget checks passed, so perform the deductions atomically: @@ -1199,20 +1191,35 @@ how locking is performed to ensure atomicity across checks and deductions, but l 1. Let |currentValue| be the result of [=map/get|getting the value=] of |key| in the [=privacy budget store=]. + 1. If |isSingleEpoch| let |deduction| be |l1Normdeduction| else let |deduction| be |valueDeduction| + 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. Let |deductedImpressionQuotas| be a new [=set=]. + + 1. Let |deductedGlobalEpochs| be a new [=set=]. + + 1. [=list/iterate|For each=] |impression| in |impressions|: + + 1. Let |impressionTime| be |impression|'s [=impression/impression time=] - 1. [=map/iterate|For each=] |impressionSite| in |impressionSites|: + 1. Let |impressionSite| be |impression|'s [=impression/impression site=]. + + 1. Let |globalEpoch| be the result of [=get the current global epoch=] + with |impressionTime|. 1. Let |impressionQuotaKey| be an [=impression site quota key=] - whose items are |epoch| and |impressionSite|. + whose items are |globalEpoch| and |impressionSite|. 1. If the [=impression site quota store=] does not [=map/contain=] |impressionQuotaKey|, [=map/set=] its value to the [=impression site quota per epoch=]. - 1. Decrement [=impression site quota store=]\[|impressionQuotaKey|] by |impressionSiteDeduction|. + 1. If |deductedImpressionQuotas| does not [=map/contain=] |impressionQuotaKey|, [=set/extend] |deductedImpressionQuotas| with |impressionQuotaKey| + and decrement [=impression site quota store=]\[|impressionQuotaKey|] by |valueDeduction|. + + 1. If |deductedGlobalEpochs| does not [=map/contain=] |globalEpoch|, [=set/extend] |deductedGlobalEpochs| with |globalEpoch| + and decrement [=global privacy budget store=]\[|globalEpoch|] by |valueDeduction|. 1. Return true. @@ -1220,75 +1227,45 @@ how locking is performed to ensure atomicity across checks and deductions, but l
In this case we need to use the global sensitivity which is the value of |deduction| only in the multi-epoch case; - so we need to recompute it in case we are in the single epoch but multiple impression sites case. + 1. If |valueDeduction| is greater than |currentGlobalValue| or greater than |currentImpressionQuotaValue|, return false. - 1. Return |globalDeduction|. +1. Return true.
This approach allows a single user action to enable multiple API invocations within the same session, while only making the API available to one site -per [=activation triggering input event|activation=]. +per activation.
One epoch (which is the epoch of the conversion site) may overlap with multiple global epochs used by the global budget - and cross-site quotas. We will look at each impression individually and map it to the global epoch it is in to deduct from the global budget - and impression site quota if those have not already deducted from in this epoch. +
One [=epoch=] (which is the epoch of the conversion site) can overlap with multiple [=safety limit epochs=] used by the [=global budget=] + and [=impression site quotas=]. We will look at each impression individually and map it to the [=safety limit epoch=] it is in to deduct from the global budget + and impression site quota if those have not already deducted from in this call to [=measure a conversion=].
TODO: Additional work to specify how locking is performed to ensure atomicity across checks and deductions, but lock should start about here. @@ -1195,21 +1196,17 @@ how locking is performed to ensure atomicity across checks and deductions, but l 1. [=map/set|Set=] the value of |key| in the [=privacy budget store=] to |currentValue| − |deduction|. - 1. Let |deductedImpressionQuotas| be a new [=set=]. - - 1. Let |deductedGlobalEpochs| be a new [=set=]. - 1. [=list/iterate|For each=] |impression| in |impressions|: 1. Let |impressionTime| be |impression|'s [=impression/timestamp=]. 1. Let |impressionSite| be |impression|'s [=impression/impression site=]. - 1. Let |globalEpoch| be the result of [=get the current global epoch=] + 1. Let |safetyLimitEpoch| be the result of [=get the current safety limit epoch=] with |impressionTime|. 1. Let |impressionQuotaKey| be an [=impression site quota key=] - whose items are |globalEpoch| and |impressionSite|. + whose items are |safetyLimitEpoch| and |impressionSite|. 1. If the [=impression site quota store=] does not [=map/contain=] |impressionQuotaKey|, [=map/set=] its value to the [=impression site quota per epoch=]. @@ -1218,9 +1215,9 @@ how locking is performed to ensure atomicity across checks and deductions, but l [=set/extend=] |deductedImpressionQuotas| with |impressionQuotaKey| and decrement [=impression site quota store=]\[|impressionQuotaKey|] by |valueDeduction|. - 1. If |deductedGlobalEpochs| does not [=map/contain=] |globalEpoch|, - [=set/extend=] |deductedGlobalEpochs| with |globalEpoch| - and decrement [=global privacy budget store=]\[|globalEpoch|] by |valueDeduction|. + 1. If |deductedGlobalBudgets| does not [=map/contain=] |safetyLimitEpoch|, + [=set/extend=] |deductedGlobalBudgets| with |safetyLimitEpoch| + and decrement [=global privacy budget store=]\[|safetyLimitEpoch|] by |valueDeduction|. 1. Return true. @@ -1246,15 +1243,15 @@ integer |valueDeduction|, 1. Let |impressionSite| be |impression|'s [=impression/impression site=]. - 1. Let |globalEpoch| be the result of [=get the current global epoch=] + 1. Let |safetyLimitEpoch| be the result of [=get the current safety limit epoch=] with |impressionTime|. 1. Let |currentGlobalValue| be set to the value of [=map/get|getting the value=] - of |globalEpoch| from the [=global privacy budget store=], or the [=global privacy budget + of |safetyLimitEpoch| from the [=global privacy budget store=], or the [=global privacy budget per epoch=] if [=map/contain|no value has been set=]. 1. Let |impressionQuotaKey| be an [=impression site quota key=] - whose items are |globalEpoch| and |impressionSite|. + whose items are |safetyLimitEpoch| and |impressionSite|. 1. Let |currentImpressionQuotaValue| be set to the value of [=map/get|getting the value=] of |impressionQuotaKey| from the [=impression site quota store=], @@ -1306,14 +1303,6 @@ This prevents a single [=impression site=] from enabling excessive budget that could be maliciously triggered. -### Epoch Cadences ### {#s-epoch-cadences} -Each site has a separate cadence for the epochs of its per-[=site=] [=privacy budget store=]. -A separate global epoch cadence is shared by the [=global privacy budget store=] and all of the - [=impression site quota stores|impression site quota store=]. This is to ensure there is - no device-specific value shared across sites that could potentially be learned through - timing and side channel attacks. - - ### Attribution API Activation ### {#s-api-activation} @@ -1445,6 +1434,37 @@ returning an [=epoch index=]:
The |deductedImpressionQuotas| and |deductedGlobalBudgets| sets are initialized outside the +loop over epochs so that [=safety limit epochs=] which overlap two per-site [=epochs=] will not be have +their safety limits deducted from more than once. + +1. For each |epoch| from |currentEpoch| to |startEpoch|, inclusive: + +
Epochs are processed sequentially starting from the most recent epoch + and going back in time as safety limit deductions may depleate from same safety limits as + the next epoch will need to check. 1. Let |impressions| be the result of invoking [=common matching logic=] with |options|, |topLevelSite|, |intermediarySite|, |epoch|, and |now|. @@ -1931,7 +1963,7 @@ To do attribution and fill a histogram, given |options|' [=validated conversion options/epsilon=], |options|' [=validated conversion options/value=], |options|'s [=validated conversion options/max value=], - |isSingleEpoch| and |l1Norm|. + |isSingleEpoch|, |l1Norm|, |deductedImpressionQuotas| and |deductedGlobalBudgets|. 1. If |budgetAndSafetyOk| is true, [=set/extend=] |matchedImpressions| with |impressions|. From bd6bb438daa3d7e5fb7504865b56996ec4613984 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:53:19 -0500 Subject: [PATCH 40/40] fix checks --- api.bs | 35 +++++++++++++++++++---------------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/api.bs b/api.bs index a6b20258..36bb4aab 100644 --- a/api.bs +++ b/api.bs @@ -1145,8 +1145,8 @@ given a [=privacy budget key=] |key|, [[WEBIDL#idl-double|double]] |epsilon|, integer |value|, integer |maxValue|, -boolean |isSingleEpoch|, integer |l1Norm|, [=set=] of [=impression quota keys=] |deductedImpressionQuotas| -and a [=set=] of [=safety limit epoch indicies=] |deductedGlobalBudgets|: +boolean |isSingleEpoch|, integer |l1Norm|, [=set=] of [=impression site quota keys=] |deductedImpressionQuotas| +and a [=set=] of [=safety limit epoch indices=] |deductedGlobalBudgets|: 1. Let |l1NormSensitivity| be |l1Norm| if |isSingleEpoch|, 2 * |value| otherwise. @@ -1171,8 +1171,8 @@ and a [=set=] of [=safety limit epoch indicies=] |deductedGlobalBudgets|: 1. Let |valueDeduction| be |valueDeductionFp| * 1000000, rounded towards positive Infinity. -
One [=epoch=] (which is the epoch of the conversion site) can overlap with multiple [=safety limit epochs=] used by the [=global budget=] - and [=impression site quotas=]. We will look at each impression individually and map it to the [=safety limit epoch=] it is in to deduct from the global budget +
One [=epoch=] (which is the epoch of the conversion site) can overlap with multiple [=safety limit epochs=] used by the [=global privacy budget store|global budget=] + and [=impression site quota store|impression site quotas=]. We will look at each impression individually and map it to the [=safety limit epoch=] it is in to deduct from the global budget and impression site quota if those have not already deducted from in this call to [=measure a conversion=].
TODO: Additional work to specify @@ -1434,30 +1434,30 @@ returning an [=epoch index=]:
Epochs are processed sequentially starting from the most recent epoch - and going back in time as safety limit deductions may depleate from same safety limits as + and going back in time as safety limit deductions can depleate from same safety limits as the next epoch will need to check. 1. Let |impressions| be the result of invoking [=common matching logic=] @@ -2970,11 +2972,12 @@ The first [[PPA-DP]] establishes the theory for on-device Individual DP accounting. The second [[PPA-DP-2]] expands the analysis to the mathematical privacy guarantees afforded by per-site budgets and by the global budget. -The per-site budgets should be seen as the primary privacy protection. Per-site budgets should be configured to provide a meaningful DP guarantee. However, the analysis in [[PPA-DP-2]] identified two assumptions that limit these guarantees: +The per-site budgets should be seen as the primary privacy protection. Per-site budgets should be configured to provide a meaningful DP guarantee. +However, the analysis in [[PPA-DP-2]] identified two assumptions that limit these guarantees: 1. *No cross-site adaptivity in data generation.* A site's queryable data stream (impressions - and conversions) must be generated independently of past DP [=attribution results=] from other sites. -1. *No leakage through cross-site shared limits.* Queries from one site must not affect which + and conversions) must be generated independently of past DP [=attribution results=] from other sites. +1. *No leakage through cross-site shared limits.* Queries from one site must not affect which reports are emitted to others. In short, neither assumption can hold in practice.