diff --git a/api.bs b/api.bs
index 3e8b23f..cce078c 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,15 +1083,27 @@ 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.
@@ -1121,14 +1132,21 @@ 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|,
+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.
@@ -1163,16 +1181,262 @@ 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. [=map/set|Set=] the value of |key| in the [=privacy budget store=]
- to |currentValue| − |deduction|
- and return true.
+ 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=]
+ 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.
+
+
+
+### 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 [=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.
+
+
+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|,
+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}
An [=epoch=] starts at a randomly-selected time
@@ -1248,6 +1512,33 @@ returning an [=epoch index=]:
+### Safety Limits Configuration ### {#safety-limits-configuration}
+
+[=User agents=] configure [=safety limits=] by defining the following values:
+
+* Global budget per epoch (εglobal):
+ The maximum privacy budget available across all [=sites=] per [=epoch=],
+ specified in [=microepsilons=].
+
+* Impression site quota per epoch (εimp-quota):
+ The maximum privacy budget that a single [=impression site=]
+ can enable to be consumed from the [=global privacy budget=] per [=epoch=],
+ specified in [=microepsilons=].
+
+* Conversion site quota per epoch (εconv-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
+
+
### Last Browsing History Clear Time ### {#last-clear}
The last browsing history clear is a [=moment=]
@@ -1367,6 +1658,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=],
@@ -1411,6 +1706,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=],
@@ -1457,8 +1753,11 @@ 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|.
@@ -1496,6 +1795,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 +1808,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 +1931,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=].
@@ -1648,7 +1952,6 @@ To do attribution and fill a histogram, given
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=]
@@ -1656,20 +1959,39 @@ 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|:
+
+ 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 |budgetOk| be the result of invoking [=deduct privacy budget=]
- with |key|, |options|' [=validated conversion options/epsilon=],
+ 1. Let |budgetAndSafetyOk| be the result of invoking [=deduct privacy and safety budgets=]
+ with |key|, |impressionsByImpSite|,
+ |options|' [=validated conversion options/epsilon=],
|options|' [=validated conversion options/value=],
|options|'s [=validated conversion options/max value=],
and null.
- 1. If |budgetOk| is true, [=set/extend=] |matchedImpressions| with |impressions|.
+ 1. If |quotaCountOk| is true and |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
@@ -1680,15 +2002,27 @@ 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 |budgetOk| be the result of [=deduct privacy budget=]
- with |key|, |options|' [=validated conversion options/epsilon=],
+ 1. Let |budgetAndSafetyOk| be the result of [=deduct privacy and safety budgets=]
+ with |key|, |impressionsByImpSite|,
+ |options|' [=validated conversion options/epsilon=],
|options|' [=validated conversion options/value=]
|options|'s [=validated conversion options/max value=],
and |l1Norm|.
- 1. If |budgetOk| 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|.