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. +

+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 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. -

-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 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: + +

+: 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 05/40] 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 1a96d90f..c47002fe 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 06/40] 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 c47002fe..40a0dab1 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 07/40] clean up --- api.bs | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/api.bs b/api.bs index 40a0dab1..cce078ca 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 From 7dd5ecd97d0449dd4067a0483400ed190399be65 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Fri, 6 Feb 2026 11:16:10 -0500 Subject: [PATCH 08/40] remove conversion site quota and store for user actions remove conversion site quota and remove the store of user action contexts, replacing with a global boolean flag attached to the window --- api.bs | 226 ++++++++++----------------------------------------------- 1 file changed, 40 insertions(+), 186 deletions(-) diff --git a/api.bs b/api.bs index 9f24bf38..1fad33fc 100644 --- a/api.bs +++ b/api.bs @@ -1097,12 +1097,6 @@ 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 [=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=] 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=]. -

-: epoch index -:: An [=epoch index=] -: conversion site -:: A [=conversion site=] +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. -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: - -

-: 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 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. -[=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).

-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, - 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=]. -Contexts typically expire after some period of inactivity -or when a new top-level navigation occurs. - -

- -
-To check impression site allowance, -given an [=impression site=] |impSite| -and a [=user action context=] |uaContext|, +To check attribution API activation, +given a {{Window}} |window|, returning a [=boolean=]: -1. Let |entry| be the result of [=map/get|getting=] |uaContext| - from the [=user action context store=]. +1. If |window|'s [=global attribution API flag=] is true, return true. -1. If |entry|'s [=user action context entry/Allowed Impression Sites=] - [=set/contains=] |impSite|, return true. +1. If |window| has [=transient activation=]: -1. If |entry|'s [=user action context entry/Impression Site Counter=] is 0, - return false. + 1. [=Consume user activation=] given |window|. -1. [=set/Append=] |impSite| to |entry|'s - [=user action context entry/Allowed Impression Sites=]. + 1. Set |window|'s [=global attribution API flag=] to true. -1. Decrement |entry|'s [=user action context entry/Impression Site Counter=] by 1. + 1. Return true. -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. +1. Return false.
@@ -1532,16 +1399,6 @@ returning an [=epoch index=]: can enable to be consumed from 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: 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}}:

@@ -2009,8 +1866,8 @@ 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|: + [=moment=] |now|, and + [=boolean=] |activationOk|: 1. Let |matchedImpressions| be an [=set/is empty|empty=] [=set=]. @@ -2037,9 +1894,6 @@ 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 |impressionsByImpSite| be a new [=map=]. 1. [=set/iterate|For each=] |impression| in |impressions|: @@ -2060,7 +1914,7 @@ To do attribution and fill a histogram, given |options|'s [=validated conversion options/max value=], and null. - 1. If |quotaCountOk| is true and |budgetAndSafetyOk| is true, + 1. If |activationOk| is true and |budgetAndSafetyOk| is true, [=set/extend=] |matchedImpressions| with |impressions|. From c42e70c9f92765584fc34824fe6b4ee98fa51382 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:41:48 -0500 Subject: [PATCH 09/40] deductions for impression sites create function to calculate deductions for impression sites. --- api.bs | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/api.bs b/api.bs index 1fad33fc..491083ea 100644 --- a/api.bs +++ b/api.bs @@ -1138,16 +1138,21 @@ 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=] |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 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. @@ -1183,9 +1188,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|, |value|, |maxValue|, |epsilon| - -

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|:

+
+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. [=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|. + +

+ @@ -1908,7 +1934,7 @@ To do attribution and fill a histogram, given 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|, |impressionsByImpSite|, + with |key|, |epoch|, |impressionsByImpSite|, |options|' [=validated conversion options/epsilon=], |options|' [=validated conversion options/value=], |options|'s [=validated conversion options/max value=], @@ -1948,7 +1974,7 @@ To do attribution and fill a histogram, given 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|, |impressionsByImpSite|, + with |key|, |currentEpoch|, |impressionsByImpSite|, |options|' [=validated conversion options/epsilon=], |options|' [=validated conversion options/value=] |options|'s [=validated conversion options/max value=], From ec0f4c9604ec95356d22c83e289a323f0459dfd9 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Fri, 6 Feb 2026 14:13:00 -0500 Subject: [PATCH 10/40] move safety configuration into implementation defined values --- api.bs | 40 +++++++++++++++++++++++----------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/api.bs b/api.bs index 491083ea..36c016bf 100644 --- a/api.bs +++ b/api.bs @@ -1412,23 +1412,6 @@ 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 enable to be consumed from the [=global privacy budget=] per [=epoch=], - specified in [=microepsilons=]. - -

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 epochglobal): + 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 epochimp-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|:

To compute impression site deductions given a [=map=] |impressionsByImpSite| from [=impression sites=] to [=sets=] of [=impressions=], -and integer |deduction|: +integer |deduction|, +integer |value|, +integer |maxValue|, +[[WEBIDL#idl-double|double]] |epsilon|, and +nullable integer |l1Norm|: -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. Let |numberImpressionSites| be the number of impression sites in |impressionsByImpSite|. -1. Let |singleEpoch| be true if |l1Norm| is non-null, false otherwise. +1. Let |singleEpoch| be true if |l1Norm| is non-null, false otherwise. 1. [=map/iterate|For each=] |impSite| → |impressions| in |impressionsByImpSite|: - 1. If |singleEpoch| is true and |numImpressionSites| is 1: + + 1. If |singleEpoch| is true and |numberImpressionSites| 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. 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 epochimp-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=].

From 1721e6ddb8ab1add749f05ed7d317389329b0e61 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Mon, 9 Feb 2026 14:41:12 -0500 Subject: [PATCH 15/40] fix link --- api.bs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.bs b/api.bs index a21d4b0e..8adcb6fc 100644 --- a/api.bs +++ b/api.bs @@ -3137,7 +3137,7 @@ for [=epochs=] from which the [=attribution logic=] selected [=impressions=]. corresponding to impressions from Sites B, C, and E. As a result, [=privacy budget=] for the [=conversion site=] - is [=deduct privacy budget|deducted=] + is [=deduct privacy and safety budgets|deducted=] from [=epochs=] 1, 3, 4, and 5. No impressions were recorded for [=epoch=] 2, so no [=privacy budget|budget=] is deducted from that [=epoch=]. From a95b7b163d38fad07139c9e3a15f3f61eb599340 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:01:41 -0500 Subject: [PATCH 16/40] update user activation checks on measure conversion --- api.bs | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/api.bs b/api.bs index 8adcb6fc..78d2948f 100644 --- a/api.bs +++ b/api.bs @@ -1734,17 +1734,6 @@ and [=implicit API inputs=] |implicitInputs|: 1. If |document| is not [=allowed to use=] the [=policy-controlled feature=] named "{{PermissionPolicy/measure-conversion}}", return [=a promise rejected with=] a {{"NotAllowedError"}} {{DOMException}} in |realm|. -1. Let |settings| be [=this=]'s [=relevant settings object=]. -1. Collect the implicit API inputs from |settings|: - 1. Let |now| be |settings|' [=environment settings object/current wall time=]. - 1. Let |topLevelSite| (the [=conversion site=]) be the result of - [=obtain a site|obtaining a site=] from the [=top-level origin=]. - 1. Let |intermediarySite| be: - 1. a value of `undefined` if the [=origin=] is [=same site=] - with the [=top-level origin=], - 1. otherwise, the result of - [=obtain a site|obtaining a site=] - from |settings|' [=environment settings object/origin=]. 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|. @@ -1979,7 +1968,7 @@ To do attribution and fill a histogram, given |options|'s [=validated conversion options/max value=], and |l1Norm|. - 1. If |budgetAndSafetyOk| is false, set |histogram| to the result of invoking + 1. If |budgetAndSafetyOk| is false or |activationOk| 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 0997b83b7cda1997b783c61681233392496f1705 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:12:34 -0500 Subject: [PATCH 17/40] clean up errors and user activation --- api.bs | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/api.bs b/api.bs index 78d2948f..25030ef7 100644 --- a/api.bs +++ b/api.bs @@ -1593,17 +1593,6 @@ and [=implicit API inputs=] |implicitInputs|: 1. If |document| is not [=allowed to use=] the [=policy-controlled feature=] named "{{PermissionPolicy/save-impression}}", return [=a promise rejected with=] a {{"NotAllowedError"}} {{DOMException}} in |realm|. -1. Let |settings| be [=this=]'s [=relevant settings object=]. -1. Collect the implicit API inputs from |settings|: - 1. Let |timestamp| be |settings|' [=environment settings object/current wall time=]. - 1. The [=impression site=] |site| is set to the result of - [=obtain a site|obtaining a site=] from the [=top-level origin=]. - 1. The [=intermediary site=] |intermediarySite| is set to - 1. a value of `undefined` if the [=origin=] is [=same site=] - with the [=top-level origin=], - 1. otherwise, the result of - [=obtain a site|obtaining a site=] - from |settings|' [=environment settings object/origin=]. 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|. From 737e81cae2b2e2ecd657a041519c7e7839b0d0d2 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Mon, 9 Feb 2026 15:19:31 -0500 Subject: [PATCH 18/40] clean up warnings --- api.bs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/api.bs b/api.bs index 25030ef7..1a6d8bef 100644 --- a/api.bs +++ b/api.bs @@ -1151,8 +1151,6 @@ 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. @@ -1246,7 +1244,7 @@ nullable integer |l1Norm|: 1. Let |singleEpoch| be true if |l1Norm| is non-null, false otherwise. -1. [=map/iterate|For each=] |impSite| → |impressions| in |impressionsByImpSite|: +1. [=map/iterate|For each=] |impSite| in |impressionsByImpSite|: 1. If |singleEpoch| is true and |numberImpressionSites| is 1: 1. [=map/Set=] |impSiteDeductions|\[|impSite|] to |deduction|. From 956417dffff27767008e321566a92c9bf399734d Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:06:09 -0500 Subject: [PATCH 19/40] address feedback --- api.bs | 89 +++++++++++++++++++++++++++++++--------------------------- 1 file changed, 47 insertions(+), 42 deletions(-) diff --git a/api.bs b/api.bs index 1a6d8bef..51c93fc6 100644 --- a/api.bs +++ b/api.bs @@ -1142,27 +1142,14 @@ 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=] |impressionsByImpSite| from [=impression sites=] to [=sets=] of [=impressions=], +a [=map=] |impressionsBySite| 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. 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. 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|:

To compute impression site deductions -given a [=map=] |impressionsByImpSite| from [=impression sites=] to [=sets=] of [=impressions=], +given a [=map=] |impressionsBySite| from [=impression sites=] to [=sets=] of [=impressions=], integer |deduction|, integer |value|, integer |maxValue|, [[WEBIDL#idl-double|double]] |epsilon|, and nullable integer |l1Norm|: -1. Let |impSiteDeductions| be a new [=map=]. +1. Let |impressionSiteDeductions| be a new [=map=]. -1. Let |numberImpressionSites| be the number of impression sites in |impressionsByImpSite|. +1. Let |numberImpressionSites| be the [=map/size|number of impression sites=] in |impressionsBySite|. 1. Let |singleEpoch| be true if |l1Norm| is non-null, false otherwise. -1. [=map/iterate|For each=] |impSite| in |impressionsByImpSite|: +1. [=map/iterate|For each=] |impressionSite| in |impressionsBySite|: 1. If |singleEpoch| is true and |numberImpressionSites| is 1: - 1. [=map/Set=] |impSiteDeductions|\[|impSite|] to |deduction|. + + 1. [=map/Set=] |impressionSiteDeductions|\[|impressionSite|] to |deduction|. + + 1. Return |impressionSiteDeductions|. 1. Otherwise: @@ -1262,9 +1267,9 @@ nullable integer |l1Norm|: 1. Let |siteDeduction| be |deductionFp| * 1000000, rounded towards positive Infinity. - 1. [=map/Set=] |impSiteDeductions|\[|impSite|] to |siteDeduction|. + 1. [=map/Set=] |impressionSiteDeductions|\[|impressionSite|] to |siteDeduction|. -1. Return |impSiteDeductions|. +1. Return |impressionSiteDeductions|.
@@ -1895,21 +1900,21 @@ To do attribution and fill a histogram, given 1. If |impressions| [=set/is empty|is not empty=]: - 1. Let |impressionsByImpSite| be a new [=map=]. + 1. Let |impressionsBySite| be a new [=map=]. 1. [=set/iterate|For each=] |impression| in |impressions|: - 1. Let |impSite| be |impression|'s [=impression/impression site=]. + 1. Let |impressionSite| be |impression|'s [=impression/impression site=]. - 1. If |impressionsByImpSite| does not [=map/contain=] |impSite|, - [=map/set=] |impressionsByImpSite|\[|impSite|] to an empty [=set=]. + 1. If |impressionsBySite| does not [=map/contain=] |impressionSite|, + [=map/set=] |impressionsBySite|\[|impressionSite|] to an empty [=set=]. - 1. [=set/Append=] |impression| to |impressionsByImpSite|\[|impSite|]. + 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|, |impressionsByImpSite|, + with |key|, |epoch|, |impressionsBySite|, |options|' [=validated conversion options/epsilon=], |options|' [=validated conversion options/value=], |options|'s [=validated conversion options/max value=], @@ -1935,21 +1940,21 @@ 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. Let |impressionsBySite| be a new [=map=]. 1. [=set/iterate|For each=] |impression| in |matchedImpressions|: - 1. Let |impSite| be |impression|'s [=impression/impression site=]. + 1. Let |impressionSite| be |impression|'s [=impression/impression site=]. - 1. If |impressionsByImpSite| does not [=map/contain=] |impSite|, - [=map/set=] |impressionsByImpSite|\[|impSite|] to an empty [=set=]. + 1. If |impressionsBySite| does not [=map/contain=] |impressionSite|, + [=map/set=] |impressionsBySite|\[|impressionSite|] to an empty [=set=]. - 1. [=set/Append=] |impression| to |impressionsByImpSite|\[|impSite|]. + 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|, |impressionsByImpSite|, + with |key|, |currentEpoch|, |impressionsBySite|, |options|' [=validated conversion options/epsilon=], |options|' [=validated conversion options/value=] |options|'s [=validated conversion options/max value=], From 9e1149e361072f606cb2cbba9fd5547a82a84133 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:44:54 -0500 Subject: [PATCH 20/40] factor out budget checks --- api.bs | 70 ++++++++++++++++++++++++++++++++++++---------------------- 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/api.bs b/api.bs index 51c93fc6..7948311e 100644 --- a/api.bs +++ b/api.bs @@ -1175,57 +1175,73 @@ nullable integer |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: 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. +

-1. All budget checks passed, so perform the deductions atomically: +
+To check for available privacy budget +given a [=privacy budget key=] |key|, +[=epoch index=] |epoch|, +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. +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. 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 epochglobal): - The maximum privacy budget available across all [=sites=] per [=epoch=], +* The global budget per epochglobal) 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 epochimp-quota): - The maximum privacy budget that a single [=impression site=] +* The impression site quota per epochimp-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

To deduct privacy and safety budgets given a [=privacy budget key=] |key|, -[=epoch index=] |epoch|, -|impressions| as a set of [=impressions=], +[=set=] of [=impressions=] |impressions|, [[WEBIDL#idl-double|double]] |epsilon|, integer |value|, integer |maxValue|, and @@ -1183,8 +1182,10 @@ nullable integer |l1Norm|: 1. [=set/Append=] |impression| to |impressionsBySite|\[|impressionSite|]. +1. Let |isSingleEpoch| be true if |l1Norm| is non-null, false otherwise. + 1. Let |impressionSiteDeductions| be the result of [=compute impression site deductions|computing impression site deductions=] - with |impressionsBySite|, |deduction|, |value|, |maxValue|, |epsilon|, and |l1Norm|. + with |impressionsBySite|, |deduction|, |value|, |maxValue|, |epsilon|, and |isSingleEpoch|.

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

To check for available privacy budget given a [=privacy budget key=] |key|, -[=epoch index=] |epoch|, integer |deduction|, and a [=map=] |impressionSiteDeductions| from [=impression sites=] to integers: @@ -1265,17 +1265,23 @@ integer |deduction|, integer |value|, integer |maxValue|, [[WEBIDL#idl-double|double]] |epsilon|, and -nullable integer |l1Norm|: +boolean |isSingleEpoch|: 1. Let |impressionSiteDeductions| be a new [=map=]. 1. Let |numberImpressionSites| be the [=map/size|number of impression sites=] in |impressionsBySite|. -1. Let |singleEpoch| be true if |l1Norm| is non-null, false otherwise. +1. Let |sensitivity| be 2 * |value|. + +1. Let |noiseScale| be 2 * |maxValue| / |epsilon|. + +1. Let |deductionFp| be |sensitivity| / |noiseScale|. + +1. Let |globalDeduction| be |deductionFp| * 1000000, rounded towards positive Infinity. 1. [=map/iterate|For each=] |impressionSite| in |impressionsBySite|: - 1. If |singleEpoch| is true and |numberImpressionSites| is 1: + 1. If |isSingleEpoch| is true and |numberImpressionSites| is 1: 1. [=map/Set=] |impressionSiteDeductions|\[|impressionSite|] to |deduction|. @@ -1286,15 +1292,7 @@ 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 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

To check attribution API activation, given a {{Window}} |window|, -returning a [=boolean=]: +throwing a {{"NotAllowedError"}} {{DOMException}} on failure: -1. If |window|'s [=global attribution API flag=] is true, return true. +1. If |window|'s [=global attribution API flag=] is true, return. 1. If |window| has [=transient activation=]: @@ -1372,9 +1372,9 @@ returning a [=boolean=]: 1. Set |window|'s [=global attribution API flag=] to true. - 1. Return true. + 1. Return. -1. Return false. +1. [=Throw=] a {{"NotAllowedError"}} {{DOMException}}.
@@ -1620,8 +1620,8 @@ and [=implicit API inputs=] |implicitInputs|: "{{PermissionPolicy/save-impression}}", return [=a promise rejected with=] a {{"NotAllowedError"}} {{DOMException}} in |realm|. 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. [=check attribution API activation|Check attribution API activation=] + given |window|, returning [=a promise rejected with=] any thrown reason. 1. Validate the page-supplied API inputs: 1. If |options|.{{AttributionImpressionOptions/histogramIndex}} is greater than or equal to the [=implementation-defined=] [=maximum histogram size=], @@ -1670,7 +1670,7 @@ and [=implicit API inputs=] |implicitInputs|: :: |options|.{{AttributionImpressionOptions/histogramIndex}} : [=impression/Priority=] :: |options|.{{AttributionImpressionOptions/priority}} - 1. If the Attribution API is [[#opt-out|enabled]] and |activationOk| is true: + 1. If the Attribution API is [[#opt-out|enabled]]: 1. Save |impression| to the [=impression store=]. 1. Let |result| be a new {{AttributionImpressionResult}}. @@ -1750,8 +1750,8 @@ and [=implicit API inputs=] |implicitInputs|: "{{PermissionPolicy/measure-conversion}}", return [=a promise rejected with=] a {{"NotAllowedError"}} {{DOMException}} in |realm|. 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. [=check attribution API activation|Check attribution API activation=] + given |window|, returning [=a promise rejected with=] any thrown reason. 1. Let |validatedOptions| be the result of [=validate AttributionConversionOptions|validating=] |options|, returning [=a promise rejected with=] any thrown reason. @@ -1759,13 +1759,12 @@ 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]] and |activationOk| is true, + 1. If the Attribution API is [[#opt-out|enabled]], 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=], - |implicitInputs|' [=implicit API inputs/timestamp=], and - |activationOk|. + |implicitInputs|' [=implicit API inputs/intermediary site=], and + |implicitInputs|' [=implicit API inputs/timestamp=]. 1. Let |aggregationService| be |validatedOptions|'s [=validated conversion options/aggregation service=]. 1. Switch on the value of |aggregationService|.{{AttributionAggregationService/protocol}}:
@@ -1894,9 +1893,8 @@ To validate {{AttributionConversionOptions}} |options|: To do attribution and fill a histogram, given [=validated conversion options=] |options|, [=site=] |topLevelSite|, - [=site=] or `undefined` |intermediarySite|, - [=moment=] |now|, and - [=boolean=] |activationOk|: + [=site=] or `undefined` |intermediarySite|, and + [=moment=] |now|: 1. Let |matchedImpressions| be an [=set/is empty|empty=] [=set=]. @@ -1932,7 +1930,7 @@ To do attribution and fill a histogram, given |options|'s [=validated conversion options/max value=], and null. - 1. If |activationOk| is true and |budgetAndSafetyOk| is true, + 1. If |budgetAndSafetyOk| is true, [=set/extend=] |matchedImpressions| with |impressions|. @@ -1961,7 +1959,7 @@ To do attribution and fill a histogram, given |options|'s [=validated conversion options/max value=], and |l1Norm|. - 1. If |budgetAndSafetyOk| is false or |activationOk| 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 28920643b98e3b763bc1d528475fe3bea43613d3 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:51:52 -0500 Subject: [PATCH 25/40] address Andrew's feedback on PR --- api.bs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/api.bs b/api.bs index 8dfa3a5d..af715b77 100644 --- a/api.bs +++ b/api.bs @@ -1191,7 +1191,7 @@ nullable integer |l1Norm|: 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|, |epoch|, |deduction|, and |impressionSiteDeductions|. + with |key|, |deduction|, and |impressionSiteDeductions|. 1. If |budgetAvailable| is false, return false. @@ -1210,6 +1210,9 @@ how locking is performed to ensure atomicity across checks and deductions, but l 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|, + [=map/set=] its value to the [=impression site quota per epoch=]. + 1. Decrement [=impression site quota store=]\[|impressionQuotaKey|] by |siteDeduction|. 1. Return true. @@ -1236,6 +1239,8 @@ a [=map=] |impressionSiteDeductions| from [=impression sites=] to integers: 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=]. @@ -1247,9 +1252,8 @@ a [=map=] |impressionSiteDeductions| from [=impression sites=] to integers: 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 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 |siteDeduction| is greater than [=impression site quota store=]\[|impressionQuotaKey|], return false. @@ -1300,7 +1304,6 @@ boolean |isSingleEpoch|: - ### Global Privacy Budget Store ### {#s-global-privacy-budget-store} The global privacy budget store is a [=map=] whose keys are @@ -1353,7 +1356,7 @@ The API can be invoked if either: 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 +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:

- ### Epoch Start Store ### {#s-epoch-start} An [=epoch=] starts at a randomly-selected time @@ -1924,7 +1926,7 @@ To do attribution and fill a histogram, given 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|, |impressions|, + with |key|, |impressions|, |options|' [=validated conversion options/epsilon=], |options|' [=validated conversion options/value=], |options|'s [=validated conversion options/max value=], @@ -1939,7 +1941,6 @@ To do attribution and fill a histogram, given [=create an all-zero histogram=] with |options|' [=validated conversion options/histogram size=]. - 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 @@ -1953,7 +1954,7 @@ To do attribution and fill a histogram, given 1. Let |key| be a [=privacy budget key=] whose items are |currentEpoch| and |topLevelSite|. 1. Let |budgetAndSafetyOk| be the result of invoking [=deduct privacy and safety budgets=] - with |key|, |currentEpoch|, |impressions|, + with |key|, |matchedImpressions|, |options|' [=validated conversion options/epsilon=], |options|' [=validated conversion options/value=] |options|'s [=validated conversion options/max value=], From 2ca92debad7eedf319e18973bea7e8ac9823eda1 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Wed, 11 Feb 2026 23:56:10 -0500 Subject: [PATCH 26/40] fix checks --- api.bs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api.bs b/api.bs index af715b77..b304cb86 100644 --- a/api.bs +++ b/api.bs @@ -1210,8 +1210,8 @@ how locking is performed to ensure atomicity across checks and deductions, but l 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|, - [=map/set=] its value to the [=impression site quota per epoch=]. + 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|. @@ -1377,7 +1377,7 @@ throwing a {{"NotAllowedError"}} {{DOMException}} on failure: 1. Return. -1. [=Throw=] a {{"NotAllowedError"}} {{DOMException}}. +1. Throw a {{"NotAllowedError"}} {{DOMException}}.
From 7b1cf1aec1e07aba2a7ded3f20dd2ce81d966ab1 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:19:03 -0500 Subject: [PATCH 27/40] description of user activation for the API adding more prose to describe what we are doing with requiring user activation and some limitations with that. --- api.bs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/api.bs b/api.bs index b304cb86..ce90cb95 100644 --- a/api.bs +++ b/api.bs @@ -1346,7 +1346,28 @@ that could be maliciously triggered. ### Attribution API Activation ### {#s-api-activation} -The Attribution API requires user activation to prevent abuse. +The Attribution API requires user activation to prevent abuse. Examples of user activations +that could be used to activate the API include +user navigation involvement or clicks on the page. +The web platform uses transient activation to limit the amount of time that a +user activation remains available for calling certain user activation-gated APIs. +The Attribution API can be activated by transient activation, +and within the time window of a user action, the site can redirect to another site +that will be able to use the transient activation if the first site has not consumed it. + +A couple of notes: + +1. Since calling the Attribution API consumes a user activation, the site would no longer have this + particular user activation to use for other APIs (e.g., opening popups). + +1. One common web pattern related to web advertising is for an advertiser site to have the user + click on a purchase button and be taken to a separate payment processor page. In such a scenario, + both sites will be able to use the API if the first site has used a different, earlier user action + to activate the API for that site (setting the window's [=global attribution API flag=] to true) + and then the second site uses the transient activation from the click to activate the API for + its use. But no further sites will be able to use the API without further user action in order + 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. @@ -1381,6 +1402,8 @@ throwing a {{"NotAllowedError"}} {{DOMException}} on failure:
+ + ### Epoch Start Store ### {#s-epoch-start} An [=epoch=] starts at a randomly-selected time From 36b54e817a96216dab5a7c40417f8e5b378f339e Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:53:30 -0500 Subject: [PATCH 28/40] remove impressionsBySite map --- api.bs | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/api.bs b/api.bs index ce90cb95..c7fb06b7 100644 --- a/api.bs +++ b/api.bs @@ -1171,21 +1171,20 @@ nullable integer |l1Norm|: 1. Let |deduction| be |deductionFp| * 1000000, rounded towards positive Infinity. -1. Let |impressionsBySite| be a new [=map=]. +1. Let |impressionsSites| be a new [=set=]. 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=] |impressionSite| to |impressionSites|. - 1. [=set/Append=] |impression| to |impressionsBySite|\[|impressionSite|]. +1. Let |numberImpressionSites| be the [=set/size|number of impression sites=] in |impressionsSites|. -1. Let |isSingleEpoch| be true if |l1Norm| is non-null, false otherwise. +1. Let |isSingleEpoch| be true if |l1Norm| is non-null, false otherwise. 1. Let |impressionSiteDeductions| be the result of [=compute impression site deductions|computing impression site deductions=] - with |impressionsBySite|, |deduction|, |value|, |maxValue|, |epsilon|, and |isSingleEpoch|. + with |numberImpressionSites|, |deduction|, |value|, |maxValue|, |epsilon|, and |isSingleEpoch|.

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:

To compute impression site deductions -given a [=map=] |impressionsBySite| from [=impression sites=] to [=sets=] of [=impressions=], +given an integer |numberImpressionSites|, integer |deduction|, integer |value|, integer |maxValue|, @@ -1273,8 +1272,6 @@ boolean |isSingleEpoch|: 1. Let |impressionSiteDeductions| be a new [=map=]. -1. Let |numberImpressionSites| be the [=map/size|number of impression sites=] in |impressionsBySite|. - 1. Let |sensitivity| be 2 * |value|. 1. Let |noiseScale| be 2 * |maxValue| / |epsilon|. From f2daa485512a55b3ad663c38f2113f74a497b3e9 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:59:29 -0500 Subject: [PATCH 29/40] address PR feedback --- api.bs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api.bs b/api.bs index c7fb06b7..632abcfa 100644 --- a/api.bs +++ b/api.bs @@ -1251,10 +1251,10 @@ a [=map=] |impressionSiteDeductions| from [=impression sites=] to integers: 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| - and |siteDeduction| is greater than the [=impression site quota per epoch=], return false. + 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 |siteDeduction| is greater than [=impression site quota store=]\[|impressionQuotaKey|], + 1. If [=map/contains] |impressionQuotaKey| and |siteDeduction| is greater than [=impression site quota store=]\[|impressionQuotaKey|], return false. 1. Return true. From d164cf764864343d9c893e6d24587da4b2595c26 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:01:28 -0500 Subject: [PATCH 30/40] fix link --- api.bs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.bs b/api.bs index 632abcfa..4654fce6 100644 --- a/api.bs +++ b/api.bs @@ -1254,7 +1254,7 @@ 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|], + 1. If [=map/contains=] |impressionQuotaKey| and |siteDeduction| is greater than [=impression site quota store=]\[|impressionQuotaKey|], return false. 1. Return true. From f740ef9bbc0337ed93a5c8a4e383c6ad003ed3a6 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:08:20 -0500 Subject: [PATCH 31/40] fix errors --- api.bs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/api.bs b/api.bs index 4654fce6..095aa438 100644 --- a/api.bs +++ b/api.bs @@ -1171,7 +1171,7 @@ nullable integer |l1Norm|: 1. Let |deduction| be |deductionFp| * 1000000, rounded towards positive Infinity. -1. Let |impressionsSites| be a new [=set=]. +1. Let |impressionSites| be a new [=set=]. 1. [=set/iterate|For each=] |impression| in |impressions|: @@ -1179,12 +1179,10 @@ nullable integer |l1Norm|: 1. [=set/append=] |impressionSite| to |impressionSites|. -1. Let |numberImpressionSites| be the [=set/size|number of impression sites=] in |impressionsSites|. - 1. Let |isSingleEpoch| be true if |l1Norm| is non-null, false otherwise. 1. Let |impressionSiteDeductions| be the result of [=compute impression site deductions|computing impression site deductions=] - with |numberImpressionSites|, |deduction|, |value|, |maxValue|, |epsilon|, and |isSingleEpoch|. + with |impressionSites|, |deduction|, |value|, |maxValue|, |epsilon|, and |isSingleEpoch|.

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:

To compute impression site deductions -given an integer |numberImpressionSites|, +given an set |impressionSites| of [=impression sites=], integer |deduction|, integer |value|, integer |maxValue|, @@ -1280,6 +1278,8 @@ boolean |isSingleEpoch|: 1. Let |globalDeduction| be |deductionFp| * 1000000, rounded towards positive Infinity. +1. Let |numberImpressionSites| be the [=set/size|number of impression sites=] in |impressionSites|. + 1. [=map/iterate|For each=] |impressionSite| in |impressionsBySite|: 1. If |isSingleEpoch| is true and |numberImpressionSites| is 1: From 580473a8fe31d35b8f3a48fbb57adea86a98c8fb Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:15:43 -0500 Subject: [PATCH 32/40] use set of impressionSites --- api.bs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.bs b/api.bs index 095aa438..bebaf6b1 100644 --- a/api.bs +++ b/api.bs @@ -1280,7 +1280,7 @@ boolean |isSingleEpoch|: 1. Let |numberImpressionSites| be the [=set/size|number of impression sites=] in |impressionSites|. -1. [=map/iterate|For each=] |impressionSite| in |impressionsBySite|: +1. [=map/iterate|For each=] |impressionSite| in |impressionSites|: 1. If |isSingleEpoch| is true and |numberImpressionSites| is 1: From 8ad8c97dca755745297720f08145e42deae153f7 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:24:18 -0500 Subject: [PATCH 33/40] remove map of impression site deductions address mt PR feedback and remove impression site map though we might want it back in the future to support https://github.com/w3c/attribution/issues/377 --- api.bs | 76 ++++++++++++++++++++++------------------------------------ 1 file changed, 29 insertions(+), 47 deletions(-) diff --git a/api.bs b/api.bs index bebaf6b1..5cd9a372 100644 --- a/api.bs +++ b/api.bs @@ -1181,16 +1181,15 @@ nullable integer |l1Norm|: 1. Let |isSingleEpoch| be true if |l1Norm| is non-null, false otherwise. -1. Let |impressionSiteDeductions| be the result of [=compute impression site deductions|computing impression site deductions=] +1. Let |impressionSiteDeduction| be the result of [=compute impression site deductions|computing impression site deductions=] with |impressionSites|, |deduction|, |value|, |maxValue|, |epsilon|, and |isSingleEpoch|.

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|.

@@ -1343,9 +1330,7 @@ that could be maliciously triggered. ### Attribution API Activation ### {#s-api-activation} -The Attribution API requires user activation to prevent abuse. Examples of user activations -that could be used to activate the API include -user navigation involvement or clicks on the page. +The Attribution API is a [=transient activation-consuming API=]. The web platform uses transient activation to limit the amount of time that a user activation remains available for calling certain user activation-gated APIs. The Attribution API can be activated by transient activation, @@ -1373,12 +1358,10 @@ 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 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=].

To check attribution API activation, @@ -1400,7 +1383,6 @@ throwing a {{"NotAllowedError"}} {{DOMException}} on failure:
- ### Epoch Start Store ### {#s-epoch-start} An [=epoch=] starts at a randomly-selected time @@ -1692,8 +1674,8 @@ and [=implicit API inputs=] |implicitInputs|: :: |options|.{{AttributionImpressionOptions/histogramIndex}} : [=impression/Priority=] :: |options|.{{AttributionImpressionOptions/priority}} - 1. If the Attribution API is [[#opt-out|enabled]]: - 1. Save |impression| to the [=impression store=]. + 1. If the Attribution API is [[#opt-out|enabled]], + save |impression| to the [=impression store=]. 1. Let |result| be a new {{AttributionImpressionResult}}. 1. Return [=a promise resolved with=] |result| in |realm|. @@ -2180,8 +2162,8 @@ it is not [=implementation-defined=].

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|.

@@ -2165,9 +2163,9 @@ is hard and therefore TBD. 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 +* The per-site privacy budgetsite) within the range of: TBD -* The global budget per epochglobal) which is +* The global privacy budget per epochglobal) which is the maximum privacy budget available across all [=sites=] per [=epoch=], specified in [=microepsilons=]. Implementations [=must=] set this value as a multiple of From d18e5eb5e4cf130e7ac00ec591f90ced5a54a237 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 19 Feb 2026 14:46:31 -0500 Subject: [PATCH 35/40] fix check --- api.bs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api.bs b/api.bs index 6c0c9c83..8dfb8ec6 100644 --- a/api.bs +++ b/api.bs @@ -2160,7 +2160,7 @@ it is not [=implementation-defined=].

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 budgetsite) 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

To check for available privacy budget -given a [=privacy budget key=] |key|, -integer |deduction|, and -an integer |impressionSiteDeduction| -and a set |impressionSites| of [=impression sites=],: +given a [=privacy budget key=] |key|, integer |l1Normdeduction|, +integer |valueDeduction|, an integer |impressionSiteDeduction|, +[=set=] of [=impressions=] |impressions| and boolean |isSingleEpoch|: 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. 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 |currentGlobalValue|, - return false. - -1. [=map/iterate|For each=] |impressionSite| in |impressionSites|: - - 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| - 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. - -1. Return true. - -
+1. If |isSingleEpoch| let |deduction| be |l1Normdeduction| else let |deduction| be |valueDeduction| -
-To compute impression site deductions -given an set |impressionSites| of [=impression sites=], -integer |deduction|, -integer |value|, -integer |maxValue|, -[[WEBIDL#idl-double|double]] |epsilon|, and -boolean |isSingleEpoch|: - -1. Let |sensitivity| be 2 * |value|. +1. If |deduction| is greater than |currentValue|, return false. -1. Let |noiseScale| be 2 * |maxValue| / |epsilon|. +1. [=list/iterate|For each=] |impression| in |impressions|: -1. Let |deductionFp| be |sensitivity| / |noiseScale|. + 1. Let |impressionTime| be |impression|'s [=impression/impression time=] -1. Let |globalDeduction| be |deductionFp| * 1000000, rounded towards positive Infinity. + 1. Let |impressionSite| be |impression|'s [=impression/impression site=]. -1. Let |numberImpressionSites| be the [=set/size|number of impression sites=] in |impressionSites|. + 1. Let |globalEpoch| be the result of [=get the current global epoch=] + with |impressionTime|. -1. If |isSingleEpoch| is true and |numberImpressionSites| is 1: + 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 + per epoch=] if [=map/contain|no value has been set=]. - 1. Return |deduction|. + 1. Let |impressionQuotaKey| be an [=impression site quota key=] + whose items are |globalEpoch| and |impressionSite| -1. Otherwise: + 1. Let |currentImpressionQuotaValue| be set to the value of [=map/get|getting the value=] + of |impressionQuotaKey| from the [=impression site quota store=], + or the [=impression site quota per epoch=] if [=map/contain|no value has been set=]. -

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.

- ### Global Privacy Budget Store ### {#s-global-privacy-budget-store} The global privacy budget store is a [=map=] whose keys are @@ -1328,6 +1305,14 @@ 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-sepecific value shared across sites that could potentially be learned through + timing and side channel attacks. + + ### Attribution API Activation ### {#s-api-activation} @@ -1912,59 +1897,52 @@ To do attribution and fill a histogram, given 1. Let |earliestEpoch| be the result of calling [=get the current epoch=], passing |topLevelSite| and (|now| − |options|' [=validated conversion options/lookback=]). -1. Let |singleEpoch| be true if |currentEpoch| is equal to |earliestEpoch|, false otherwise. +1. Let |isSingleEpoch| be true if |currentEpoch| is equal to |earliestEpoch|, false otherwise. -1. If |singleEpoch| is true: +1. If |isSingleEpoch| is true: 1. Set |matchedImpressions| to the result of invoking [=common matching logic=] with |options|, |topLevelSite|, |intermediarySite|, |currentEpoch|, and |now|. -1. If |singleEpoch| is false: - 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|. - - 1. If |impressions| [=set/is empty|is not empty=]: - - 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|, |impressions|, - |options|' [=validated conversion options/epsilon=], - |options|' [=validated conversion options/value=], - |options|'s [=validated conversion options/max value=], - and null. - - 1. If |budgetAndSafetyOk| is true, - [=set/extend=] |matchedImpressions| with |impressions|. - - - -1. If |matchedImpressions| [=set/is empty=], return the result of invoking + 1. If |matchedImpressions| [=set/is empty=], return the result of invoking [=create an all-zero histogram=] with |options|' [=validated conversion options/histogram size=]. -1. Set |histogram| to the result of [=fill a histogram with last-n-touch attribution=] with |matchedImpressions|, + 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 |options|' [=validated conversion options/credit=]. -1. If |singleEpoch| is true: 1. Let |l1Norm| be the sum of the [=list/items=] in |histogram|. 1. [=Assert=]: |l1Norm| is less than or equal to |options|' [=validated conversion options/value=]. - 1. Let |key| be a [=privacy budget key=] whose items are |currentEpoch| and |topLevelSite|. +1. For each |epoch| from |startEpoch| to |currentEpoch|, inclusive: - 1. Let |budgetAndSafetyOk| be the result of invoking [=deduct privacy and safety budgets=] - with |key|, |matchedImpressions|, - |options|' [=validated conversion options/epsilon=], - |options|' [=validated conversion options/value=] - |options|'s [=validated conversion options/max value=], - and |l1Norm|. + 1. Let |impressions| be the result of invoking [=common matching logic=] + with |options|, |topLevelSite|, |intermediarySite|, |epoch|, and |now|. - 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. If |impressions| [=set/is empty|is not empty=]: + + 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|, |impressions|, + |options|' [=validated conversion options/epsilon=], + |options|' [=validated conversion options/value=], + |options|'s [=validated conversion options/max value=], + |isSingleEpoch| and |l1Norm|. + + 1. If |budgetAndSafetyOk| is true, + [=set/extend=] |matchedImpressions| with |impressions|. + +1. If |matchedImpressions| [=set/is empty=], return the result of invoking +[=create an all-zero histogram=] with +|options|' [=validated conversion options/histogram size=]. + +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 +|options|' [=validated conversion options/credit=]. 1. Return |histogram|. From f0242f98b215725e167cb0a88ac66788062f03e1 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 26 Feb 2026 19:00:51 -0500 Subject: [PATCH 38/40] fix checks --- api.bs | 73 +++++++++++++++++++++++++++++----------------------------- 1 file changed, 37 insertions(+), 36 deletions(-) diff --git a/api.bs b/api.bs index 6fede443..de7c5339 100644 --- a/api.bs +++ b/api.bs @@ -1147,8 +1147,6 @@ integer |value|, integer |maxValue|, boolean |isSingleEpoch| and integer |l1Norm|: -1. Let |epoch| be the [=epoch index=] component of |key|. - 1. Let |l1NormSensitivity| be |l1Norm| if |isSingleEpoch|, 2 * |value| otherwise. 1. Let |valueSensitivity| be 2 * |value|. @@ -1180,7 +1178,8 @@ boolean |isSingleEpoch| and 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|, |l1Normdeduction|, |valueDeduction|, |impressions|, + with |key|, |l1Normdeduction|, |valueDeduction|, + |impressions|, and |isSingleEpoch|, is false, return false. 1. All budget checks passed, so perform the deductions atomically: @@ -1202,12 +1201,12 @@ how locking is performed to ensure atomicity across checks and deductions, but l 1. [=list/iterate|For each=] |impression| in |impressions|: - 1. Let |impressionTime| be |impression|'s [=impression/impression time=] + 1. Let |impressionTime| be |impression|'s [=impression/timestamp=]. - 1. Let |impressionSite| be |impression|'s [=impression/impression site=]. + 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 |globalEpoch| be the result of [=get the current global epoch=] + with |impressionTime|. 1. Let |impressionQuotaKey| be an [=impression site quota key=] whose items are |globalEpoch| and |impressionSite|. @@ -1215,11 +1214,13 @@ 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. If |deductedImpressionQuotas| does not [=map/contain=] |impressionQuotaKey|, [=set/extend] |deductedImpressionQuotas| with |impressionQuotaKey| - and decrement [=impression site quota store=]\[|impressionQuotaKey|] by |valueDeduction|. + 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. If |deductedGlobalEpochs| does not [=map/contain=] |globalEpoch|, + [=set/extend=] |deductedGlobalEpochs| with |globalEpoch| + and decrement [=global privacy budget store=]\[|globalEpoch|] by |valueDeduction|. 1. Return true. @@ -1228,7 +1229,7 @@ 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 |l1Normdeduction|, -integer |valueDeduction|, an integer |impressionSiteDeduction|, +integer |valueDeduction|, [=set=] of [=impressions=] |impressions| and boolean |isSingleEpoch|: 1. Let |currentValue| be set to the value of [=map/get|getting the value=] of |key| @@ -1241,23 +1242,23 @@ integer |valueDeduction|, an integer |impressionSiteDeduction|, 1. [=list/iterate|For each=] |impression| in |impressions|: - 1. Let |impressionTime| be |impression|'s [=impression/impression time=] + 1. Let |impressionTime| be |impression|'s [=impression/timestamp=]. - 1. Let |impressionSite| be |impression|'s [=impression/impression site=]. + 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 |globalEpoch| be the result of [=get the current global 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 - per epoch=] if [=map/contain|no value has been set=]. + 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 + 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| + 1. Let |impressionQuotaKey| be an [=impression site quota key=] + whose items are |globalEpoch| and |impressionSite|. - 1. Let |currentImpressionQuotaValue| be set to the value of [=map/get|getting the value=] - of |impressionQuotaKey| from the [=impression site quota store=], - or the [=impression site quota per epoch=] if [=map/contain|no value has been set=]. + 1. Let |currentImpressionQuotaValue| be set to the value of [=map/get|getting the value=] + of |impressionQuotaKey| from the [=impression site quota store=], + or the [=impression site quota per epoch=] if [=map/contain|no value has been set=]. 1. If |valueDeduction| is greater than |currentGlobalValue| or greater than |currentImpressionQuotaValue|, return false. @@ -1309,7 +1310,7 @@ that could be maliciously triggered. 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-sepecific value shared across sites that could potentially be learned through + no device-specific value shared across sites that could potentially be learned through timing and side channel attacks. @@ -1347,7 +1348,7 @@ The API can be invoked if either:

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.

To check attribution API activation, @@ -1904,13 +1905,13 @@ To do attribution and fill a histogram, given with |options|, |topLevelSite|, |intermediarySite|, |currentEpoch|, and |now|. 1. If |matchedImpressions| [=set/is empty=], return the result of invoking - [=create an all-zero histogram=] with - |options|' [=validated conversion options/histogram size=]. + [=create an all-zero histogram=] with + |options|' [=validated conversion options/histogram size=]. 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 - |options|' [=validated conversion options/credit=]. + |options|' [=validated conversion options/histogram size=], + |options|' [=validated conversion options/value=], and + |options|' [=validated conversion options/credit=]. 1. Let |l1Norm| be the sum of the [=list/items=] in |histogram|. @@ -1936,13 +1937,13 @@ To do attribution and fill a histogram, given [=set/extend=] |matchedImpressions| with |impressions|. 1. If |matchedImpressions| [=set/is empty=], return the result of invoking -[=create an all-zero histogram=] with -|options|' [=validated conversion options/histogram size=]. + [=create an all-zero histogram=] with + |options|' [=validated conversion options/histogram size=]. 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 -|options|' [=validated conversion options/credit=]. + |options|' [=validated conversion options/histogram size=], + |options|' [=validated conversion options/value=], and + |options|' [=validated conversion options/credit=]. 1. Return |histogram|. From 711496b663a407960ab39c7cc65959ebe25ebb60 Mon Sep 17 00:00:00 2001 From: "Benjamin M. Case" <35273659+bmcase@users.noreply.github.com> Date: Thu, 26 Feb 2026 21:45:14 -0500 Subject: [PATCH 39/40] safety limit epoch start --- api.bs | 84 ++++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 58 insertions(+), 26 deletions(-) diff --git a/api.bs b/api.bs index de7c5339..a6b20258 100644 --- a/api.bs +++ b/api.bs @@ -1145,7 +1145,8 @@ given a [=privacy budget key=] |key|, [[WEBIDL#idl-double|double]] |epsilon|, integer |value|, integer |maxValue|, -boolean |isSingleEpoch| and integer |l1Norm|: +boolean |isSingleEpoch|, integer |l1Norm|, [=set=] of [=impression quota keys=] |deductedImpressionQuotas| +and a [=set=] of [=safety limit epoch indicies=] |deductedGlobalBudgets|: 1. Let |l1NormSensitivity| be |l1Norm| if |isSingleEpoch|, 2 * |value| otherwise. @@ -1170,9 +1171,9 @@ boolean |isSingleEpoch| and integer |l1Norm|: 1. Let |valueDeduction| be |valueDeductionFp| * 1000000, rounded towards positive Infinity. -

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=]:

+### Saftety Limit Epoch Start ### {#s-epoch-start} +A [=safety limit epoch=] starts at the same time for all devices. This +ensures that it does not create a device-specific value that could be learned +through timing or other side-channel attacks. + +A safety limit epoch identifies a period of time. The length +of a [=safety limit epoch=] is fixed at one [=day=]. The start time for an +[=safetly limit epoch=] is defined relative to the [=Unix epoch=]. + +An safety limit epoch index is an integer +that refers to a given [=safety limit epoch=]. +An [=safety limit epoch index=] is used to access the +[=global privacy budget store=] and the [=impression site quota stores=]. + +[=moment|Points in time=] are translated to an [=safety limit epoch index=] +for the corresponding [=safetly limit epoch=] +using the [=get the current safetly limit epoch=] algorithm. + +
+To get the current safety limit epoch +given [=moment=] |t|, +returning a [=safety limit epoch index=]: + +1. Let |period| be the [=duration=] of one [=epoch=]. + +1. Let |start| be the [=Unix epoch=]. + +1. Let |elapsed| be (|t| − |start|) / |period|. + +1. Return |elapsed| as an integer, rounded towards negative Infinity. + ### Last Browsing History Clear Time ### {#last-clear} The last browsing history clear is a [=moment=] @@ -1917,7 +1937,19 @@ To do attribution and fill a histogram, given 1. [=Assert=]: |l1Norm| is less than or equal to |options|' [=validated conversion options/value=]. -1. For each |epoch| from |startEpoch| to |currentEpoch|, inclusive: +1. Let |deductedImpressionQuotas| be a new [=set=]. + +1. Let |deductedGlobalBudgets| be a new [=set=]. + +

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=]:

-### Saftety Limit Epoch Start ### {#s-epoch-start} +### Safety Limit Epoch Start ### {#s-safety-limit-epoch-start} A [=safety limit epoch=] starts at the same time for all devices. This ensures that it does not create a device-specific value that could be learned through timing or other side-channel attacks. A safety limit epoch identifies a period of time. The length -of a [=safety limit epoch=] is fixed at one [=day=]. The start time for an -[=safetly limit epoch=] is defined relative to the [=Unix epoch=]. +of a [=safety limit epoch=] is fixed at one [=day=]. The start time for a +[=safety limit epoch=] is defined relative to the [=Unix epoch=]. An safety limit epoch index is an integer that refers to a given [=safety limit epoch=]. An [=safety limit epoch index=] is used to access the -[=global privacy budget store=] and the [=impression site quota stores=]. +[=global privacy budget store=] and the [=impression site quota store|impression site quota stores=]. -[=moment|Points in time=] are translated to an [=safety limit epoch index=] -for the corresponding [=safetly limit epoch=] -using the [=get the current safetly limit epoch=] algorithm. +[=moment|Points in time=] are translated to a [=safety limit epoch index=] +for the corresponding [=safety limit epoch=] +using the [=get the current safety limit epoch=] algorithm.
To get the current safety limit epoch given [=moment=] |t|, returning a [=safety limit epoch index=]: -1. Let |period| be the [=duration=] of one [=epoch=]. +1. Let |period| be the [=duration=] of one [=safety limit epoch=]. 1. Let |start| be the [=Unix epoch=]. @@ -1465,6 +1465,8 @@ returning a [=safety limit epoch index=]: 1. Return |elapsed| as an integer, rounded towards negative Infinity. +
+ ### Last Browsing History Clear Time ### {#last-clear} The last browsing history clear is a [=moment=] @@ -1948,7 +1950,7 @@ 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 + 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.