diff --git a/AGGREGATE.md b/AGGREGATE.md index 9f90ab1538..e91f8b38b7 100644 --- a/AGGREGATE.md +++ b/AGGREGATE.md @@ -551,6 +551,56 @@ as when a trigger context ID is set. See [flexible_filtering.md](https://github.com/patcg-individual-drafts/private-aggregation-api/blob/main/flexible_filtering.md) for more details. +### Optional: named budgets + +Named budgets is an optional feature that gives API callers the ability +to manage `L1` contribution budget distribution across different types of +attributions, addressing common challenges such as: + +- Allocating the privacy budget between different types of attributions + (e.g., biddable vs. non-biddable). +- Distributing the budget across multiple product categories to prevent any + single product category from consuming all available privacy budget. + +[Source registrations](#attribution-source-registration) will accept an optional +field `named_budgets`, which is a dictionary used to set the +maximum contribution for each named budget for this source. + +```jsonc +{ + ..., // existing fields + "named_budgets": { + "budgetName1": 32768, // Max contribution budget for budgetName1. + "budgetName2": 32768 // Max contribution budget for budgetName2. + } +} +``` + +[Trigger registrations](#attribution-trigger-registration) will accept an +optional field `named_budgets`, which will be used to select the +named budget for the generated aggregatable report. + +```jsonc +{ + ..., // existing fields + "named_budgets": [ + { + "name": "budgetName1", + "filters": {"source_type": ["navigation"]} + } + ] +} +``` + +The first named budget from the trigger that matches the source's filter data +will be selected. If there is no budget name specified or no matching filters, the +`L1` contribution budget will still be applied. + +When generating an aggregatable report, in addition to performing the +current `L1` budget limit check, the contributions for the report will +be checked against the available budget with the selected budget name, if applicable. +If the budget is insufficient, the aggregatable report will not be generated. + ## Data processing through a Secure Aggregation Service The exact design of the service is not specified here. We expect to have more diff --git a/index.bs b/index.bs index b09f206013..64ad5198a8 100644 --- a/index.bs +++ b/index.bs @@ -834,6 +834,12 @@ An attribution source is a [=struct=] with the following items: non-negative 128-bit integers. : remaining aggregatable attribution budget :: A non-negative integer. +: named budgets +:: A [=map=] whose [=map/key|keys=] are [=strings=] and whose [=map/value|values=] are + non-negative integers. +: remaining named budgets +:: A [=map=] whose [=map/key|keys=] are [=strings=] and whose [=map/value|values=] are + non-negative integers. : aggregatable dedup keys :: A [=set=] of [=aggregatable dedup key/dedup key|aggregatable dedup key values=] associated with this [=attribution source=]. : debug reporting enabled @@ -918,6 +924,20 @@ An aggregatable dedup key is a [=struct=] with the following items: +

Named budget

+ +A named budget is a [=struct=] with the following items: + +
+: name +:: Null or a [=string=]. +: filters +:: A [=list=] of [=filter configs=]. +: negated filters +:: A [=list=] of [=filter configs=]. + +
+

Event-level trigger configuration

An event-level trigger configuration is a [=struct=] with the following items: @@ -996,6 +1016,8 @@ An attribution trigger is a [=struct=] with the following items: :: An [=aggregatable debug reporting config=]. : attribution scopes :: A [=set=] of [=strings=]. +: named budgets +:: A [=list=] of [=named budgets=]. @@ -1185,6 +1207,7 @@ Possible values are:
  • "trigger-aggregate-deduplicated"
  • "trigger-aggregate-excessive-reports"
  • "trigger-aggregate-insufficient-budget" +
  • "trigger-aggregate-insufficient-named-budget"
  • "trigger-aggregate-no-contributions"
  • "trigger-aggregate-report-window-passed"
  • "trigger-aggregate-storage-limit" @@ -1414,6 +1437,18 @@ Its value is 50. maximum [=set/size=] of an [=attribution source=]'s [=attribution scopes/values=]. Its value is 20. +Max length per budget name for source is a positive integer that controls +the maximum [=string/length=] of keys of an [=attribution source=]'s +[=attribution source/named budgets=] and +[=attribution source/remaining named budgets=]. +Its value is 25. + +Max named budgets per source registration is a positive integer that +controls the maximum [=map/size=] of an [=attribution source=]'s +[=attribution source/remaining named budgets=] +and [=attribution source/named budgets=]. +Its value is 25. + # Vendor-Specific Values # {#vendor-specific-values} Max pending sources per source origin is a positive integer that @@ -2383,6 +2418,7 @@ A source-registration JSON key is one of the following:
  • "limit"
  • "max_event_level_reports"
  • "max_event_states" +
  • "named_budgets"
  • "priority"
  • "source_event_id"
  • "start_time" @@ -2459,6 +2495,22 @@ To parse aggregation keys given a [=map=] |map|: 1. [=map/Set=] |aggregationKeys|[|key|] to |keyPiece|. 1. Return |aggregationKeys|. +To parse named budgets for source given a [=map=] |map|: + +1. Let |namedBudgets| be a new [=map=]. +1. If |map|["[=source-registration JSON key/named_budgets=]"] does not [=map/exists|exist=], return |namedBudgets|. +1. Let |values| be |map|["[=source-registration JSON key/named_budgets=]"]. +1. If |values| is not a [=map=], return an error. +1. If |values|'s [=map/size=] is greater than the + [=max named budgets per source registration=], return an error. +1. [=map/iterate|For each=] |key| → |value| of |values|: + 1. If |key|'s [=string/length=] is greater than the [=max length per budget name for source=], + return an error. + 1. If |value| is not an integer or is less than 0 or is greater than + [=allowed aggregatable budget per source=], return an error. + 1. [=map/Set=] |namedBudgets|[|key|] to |value|. +1. Return |namedBudgets|. + To obtain default effective windows given a [=source type=] |sourceType|, a [=moment=] |sourceTime|, and a [=duration=] |eventReportWindow|: @@ -2785,6 +2837,8 @@ To parse source-registration JSON given a [=byte sequence=] |value|["[=source-registration JSON key/aggregatable_debug_reporting=]"], |aggregatableDebugBudget|, and |aggregatableDebugReportingConfig|. 1. Let |aggregatableAttributionBudget| be [=allowed aggregatable budget per source=] - |aggregatableDebugBudget|. +1. Let |namedBudgets| be the result of [=parsing named budgets for source=] with |value|. +1. If |namedBudgets| is an error, return it. 1. If [=automation local testing mode=] is true, set |epsilon| to `∞`. 1. Let |source| be a new [=attribution source=] struct whose items are: @@ -2824,6 +2878,10 @@ To parse source-registration JSON given a [=byte sequence=] :: |aggregationKeys| : [=attribution source/remaining aggregatable attribution budget=] :: |aggregatableAttributionBudget| + : [=attribution source/named budgets=] + :: |namedBudgets| + : [=attribution source/remaining named budgets=] + :: |namedBudgets| : [=attribution source/debug reporting enabled=] :: |debugReportingEnabled| : [=attribution source/trigger-data matching mode=] @@ -3385,6 +3443,8 @@ A trigger-registration JSON key is one of the following:
  • "filtering_id"
  • "filters"
  • "key_piece" +
  • "name" +
  • "named_budgets"
  • "not_filters"
  • "priority"
  • "source_keys" @@ -3589,6 +3649,31 @@ To parse aggregatable dedup keys given a [=map=] |map|: 1. [=set/Append=] |aggregatableDedupKey| to |aggregatableDedupKeys|. 1. Return |aggregatableDedupKeys|. +To parse named budgets for trigger given a [=map=] |map|: + +1. Let |namedBudgets| be a new [=list=]. +1. If |map|["[=trigger-registration JSON key/named_budgets=]"] does not [=map/exist=], return |namedBudgets|. +1. Let |values| be |map|["[=trigger-registration JSON key/named_budgets=]"]. +1. If |values| is not a [=list=], return an error. +1. [=list/iterate|For each=] |value| of |values|: + 1. If |value| is not a [=map=], return an error. + 1. Let |name| be null. + 1. If |map|["[=trigger-registration JSON key/name=]"] [=map/exists=]: + 1. Set |name| to |value|["[=trigger-registration JSON key/name=]"]. + 1. If |name| is not a [=string=], return an error. + 1. Let |filterPair| be the result of running [=parse a filter pair=] with + |value|. + 1. If |filterPair| is an error, return it. + 1. Let |namedBudget| be a new [=named budget=] with the items: + : [=named budget/name=] + :: |name| + : [=named budget/filters=] + :: |filterPair|[0] + : [=named budget/negated filters=] + :: |filterPair|[1] + 1. [=list/Append=] |namedBudget| to |namedBudgets|. +1. Return |namedBudgets|. + To parse attribution scopes for trigger from a [=map=] |map|: 1. Let |result| be a new [=set=]. 1. If |map|["[=trigger-registration JSON key/attribution_scopes=]"] does not [=map/exist=], return |result|. @@ -3620,6 +3705,9 @@ a [=moment=] |triggerTime|, and a [=boolean=] |fenced|: 1. Let |aggregatableDedupKeys| be the result of running [=parse aggregatable dedup keys=] with |value|. 1. If |aggregatableDedupKeys| is an error, return it. +1. Let |namedBudgets| be the result of running [=parse named budgets for trigger=] + with |value|. +1. If |namedBudgets| is an error, return it. 1. Let |debugKey| be the result of running [=parse an optional 64-bit unsigned integer=] with |value|, "[=trigger-registration JSON key/debug_key=]", and null. @@ -3860,6 +3948,24 @@ To check if an [=attribution source=] can create [=aggregatable contributio |remainingAggregatableBudget|, return false. 1. Return true. +To find matching budget name given an [=attribution trigger=] |trigger| and an [=attribution source=] |sourceToAttribute|: +1. [=list/iterate|For each=] [=named budget=] |namedBudget| of |trigger|'s [=attribution trigger/named budgets=]: + 1. If the result of running [=match an attribution source against filters and negated filters=] + with |sourceToAttribute|, |namedBudget|'s [=named budget/filters=], + |namedBudget|'s [=named budget/negated filters=], and + |trigger|'s [=attribution trigger/trigger time=] is true: + 1. Return |namedBudget|'s [=named budget/name=]. +1. Return null. + +To check if an [=attribution source=] can create [=aggregatable contributions=] for matched budget name +given an [=aggregatable attribution report=] |report|, an [=attribution source=] |sourceToAttribute|, +and a [=string=] |matchedBudgetName|, run the following steps: + +1. If |sourceToAttribute|'s [=attribution source/remaining named budgets=][|matchedBudgetName|] does not [=map/exists|exist=], return true. +1. If |report|'s [=aggregatable attribution report/required aggregatable budget=] is greater than + |sourceToAttribute|'s [=attribution source/remaining named budgets=][|matchedBudgetName|], return false. +1. Return true. +

    Obtaining verbose debug data on trigger registration

    To obtain verbose debug data body on trigger registration given a @@ -3886,6 +3992,14 @@ a possibly null [=attribution source=] |sourceToAttribute|, and a possibly null : "[=trigger debug data type/trigger-aggregate-insufficient-budget=]" :: [=map/Set=] |body|["`limit`"] to [=allowed aggregatable budget per source=], [=serialize an integer|serialized=]. + : "[=trigger debug data type/trigger-aggregate-insufficient-named-budget=]" + :: + 1. [=Assert=]: |sourceToAttribute| is not null. + 1. Let |matchedBudgetName| be the result of running [=find matching budget name=] with |trigger| and |sourceToAttribute|. + 1. [=Assert=]: |matchedBudgetName| is not null and |sourceToAttribute|'s [=attribution source/named budgets=][|matchedBudgetName|] [=map/exists=]. + 1. [=map/Set=] |body|["`name`"] to |matchedBudgetName|. + 1. [=map/Set=] |body|["`limit`"] to |sourceToAttribute|'s [=attribution source/named budgets=][|matchedBudgetName|], + [=serialize an integer|serialized=]. : "[=trigger debug data type/trigger-aggregate-excessive-reports=]" :: [=map/Set=] |body|["`limit`"] to [=max aggregatable reports per source=][0], : "[=trigger debug data type/trigger-event-low-priority=]" @@ -4156,10 +4270,18 @@ To trigger aggregatable attribution given an [=attribution trigger=] with |report| and |sourceToAttribute| is false: 1. Return the [=triggering result=] ("[=triggering status/dropped=]", ("[=trigger debug data type/trigger-aggregate-insufficient-budget=]", null)). +1. Let |matchedBudgetName| be the result of running [=find matching budget name=] with |trigger| and |sourceToAttribute|. +1. If |matchedBudgetName| is not null and the result of running [=check if an attribution source can create aggregatable contributions for matched budget name=] + with |report|, |sourceToAttribute|, and |matchedBudgetName| is false: + 1. Return the [=triggering result=] ("[=triggering status/dropped=]", + ("[=trigger debug data type/trigger-aggregate-insufficient-named-budget=]", null)). 1. [=set/Append=] |report| to the [=aggregatable attribution report cache=]. 1. Increment |sourceToAttribute|'s [=attribution source/number of aggregatable attribution reports=] value by 1. 1. Decrement |sourceToAttribute|'s [=attribution source/remaining aggregatable attribution budget=] value by |report|'s [=aggregatable attribution report/required aggregatable budget=]. +1. If |matchedBudgetName| is not null and |sourceToAttribute|'s [=attribution source/remaining named budgets=][|matchedBudgetName|] [=map/exists=]: + 1. Decrement |sourceToAttribute|'s [=attribution source/remaining named budgets=][|matchedBudgetName|] value by + |report|'s [=aggregatable attribution report/required aggregatable budget=]. 1. If |matchedDedupKey| is not null, [=list/append=] it to |sourceToAttribute|'s [=attribution source/aggregatable dedup keys=]. 1. [=set/Append=] |rateLimitRecord| to the [=attribution rate-limit cache=]. 1. Run [=generate null attribution reports=] with |trigger| and |report|. diff --git a/ts/src/constants.ts b/ts/src/constants.ts index 9aab339bbe..e906dad64d 100644 --- a/ts/src/constants.ts +++ b/ts/src/constants.ts @@ -84,6 +84,7 @@ export const triggerAggregatableDebugTypes: Readonly<[string, ...string[]]> = [ 'trigger-aggregate-excessive-reports', 'trigger-aggregate-no-contributions', 'trigger-aggregate-insufficient-budget', + 'trigger-aggregate-insufficient-named-budget', 'trigger-aggregate-storage-limit', 'trigger-aggregate-report-window-passed', 'trigger-event-attributions-per-source-destination-limit', @@ -104,3 +105,7 @@ export const triggerAggregatableDebugTypes: Readonly<[string, ...string[]]> = [ ] export const defaultMaxEventStates: number = 3 + +export const maxNamedBudgetsPerSource: number = 25 + +export const maxLengthPerBudgetName: number = 25 diff --git a/ts/src/header-validator/index.html b/ts/src/header-validator/index.html index 8494790117..1fa1c8e461 100644 --- a/ts/src/header-validator/index.html +++ b/ts/src/header-validator/index.html @@ -85,6 +85,7 @@

    Attribution Reporting Header Validation

    (details)

    (details) +

    (details)

    Validation Result diff --git a/ts/src/header-validator/index.ts b/ts/src/header-validator/index.ts index aad868ce64..6c7510162d 100644 --- a/ts/src/header-validator/index.ts +++ b/ts/src/header-validator/index.ts @@ -23,6 +23,9 @@ const sourceTypeFieldset = const effective = document.querySelector('#effective')! const flexCheckbox = form.elements.namedItem('flex') as HTMLInputElement +const namedBudgetsCheckbox = form.elements.namedItem( + 'namedBudgets' +) as HTMLInputElement function sourceType(): SourceType { return parseSourceType(sourceTypeRadios.value) @@ -31,6 +34,7 @@ function sourceType(): SourceType { function validate(): void { sourceTypeFieldset.disabled = true flexCheckbox.disabled = true + namedBudgetsCheckbox.disabled = true let v: validator.Validator @@ -38,18 +42,22 @@ function validate(): void { case 'source': sourceTypeFieldset.disabled = false flexCheckbox.disabled = false + namedBudgetsCheckbox.disabled = false v = source.validator({ vsv: vsv.Chromium, sourceType: sourceType(), fullFlex: flexCheckbox.checked, + namedBudgets: namedBudgetsCheckbox.checked, noteInfoGain: true, }) break case 'trigger': flexCheckbox.disabled = false + namedBudgetsCheckbox.disabled = false v = trigger.validator({ vsv: vsv.Chromium, fullFlex: flexCheckbox.checked, + namedBudgets: namedBudgetsCheckbox.checked, }) break case 'os-source': @@ -100,6 +108,7 @@ document.querySelector('#linkify')!.addEventListener('click', () => { } url.searchParams.set('flex', flexCheckbox.checked.toString()) + url.searchParams.set('namedBudgets', namedBudgetsCheckbox.checked.toString()) void navigator.clipboard.writeText(url.toString()) }) @@ -137,5 +146,6 @@ if (st !== null && st in SourceType) { sourceTypeRadios.value = st flexCheckbox.checked = params.get('flex') === 'true' +namedBudgetsCheckbox.checked = params.get('namedBudgets') === 'true' validate() diff --git a/ts/src/header-validator/main.ts b/ts/src/header-validator/main.ts index 7ce094432f..6e851007e1 100644 --- a/ts/src/header-validator/main.ts +++ b/ts/src/header-validator/main.ts @@ -12,6 +12,7 @@ interface Arguments { file?: string fullFlex: boolean + namedBudgets: boolean sourceType?: SourceType silent: boolean @@ -48,6 +49,11 @@ const options = parse( description: 'If true, parse experimental Full Flexible Event fields.', }, + namedBudgets: { + type: Boolean, + description: 'If true, parse experimental Named Budgets fields.', + }, + silent: { type: Boolean, description: 'If true, suppress output.', @@ -95,10 +101,12 @@ const out = validate( ? trigger.validator({ vsv: vsv.Chromium, fullFlex: options.fullFlex, + namedBudgets: options.namedBudgets, }) : source.validator({ vsv: vsv.Chromium, fullFlex: options.fullFlex, + namedBudgets: options.namedBudgets, sourceType: options.sourceType, }) ) diff --git a/ts/src/header-validator/source.test.ts b/ts/src/header-validator/source.test.ts index 149ef59d50..ec320baa43 100644 --- a/ts/src/header-validator/source.test.ts +++ b/ts/src/header-validator/source.test.ts @@ -49,9 +49,14 @@ const testCases: TestCase[] = [ "limit": 3, "values": ["1"], "max_event_states": 4 + }, + "named_budgets": { + "1": 32768, + "2": 32769 } }`, sourceType: SourceType.navigation, + parseNamedBudgets: true, expected: Maybe.some({ aggregatableReportWindow: 3601, aggregationKeys: new Map([['a', 15n]]), @@ -95,6 +100,10 @@ const testCases: TestCase[] = [ values: new Set('1'), maxEventStates: 4, }, + namedBudgets: new Map([ + ['1', 32768], + ['2', 32769], + ]), }), }, @@ -3065,6 +3074,146 @@ const testCases: TestCase[] = [ }, ], }, + + // Named budgets. + { + name: 'named-budgets-not-a-dictionary', + input: `{ + "destination": "https://a.test", + "named_budgets": ["1"] + }`, + sourceType: SourceType.navigation, + parseNamedBudgets: true, + expectedErrors: [ + { + msg: 'must be an object', + path: ['named_budgets'], + }, + ], + }, + { + name: 'budget-name-too-long', + input: `{ + "destination": "https://a.test", + "named_budgets": { + "aaaaaaaaaaaaaaaaaaaaaaaaaa": 32768 + } + }`, + sourceType: SourceType.navigation, + parseNamedBudgets: true, + expectedErrors: [ + { + msg: 'name exceeds max length per budget name (26 > 25)', + path: ['named_budgets', 'aaaaaaaaaaaaaaaaaaaaaaaaaa'], + }, + ], + }, + { + name: 'budget-name-empty', + input: `{ + "destination": "https://a.test", + "named_budgets": { + "": 32768 + } + }`, + sourceType: SourceType.navigation, + parseNamedBudgets: true, + }, + { + name: 'named-budgets-too-many', + input: `{ + "destination": "https://a.test", + "named_budgets": { + "1": 32768, + "2": 32768, + "3": 32768, + "4": 32768, + "5": 32768, + "6": 32768, + "7": 32768, + "8": 32768, + "9": 32768, + "10": 32768, + "11": 32768, + "12": 32768, + "13": 32768, + "14": 32768, + "15": 32768, + "16": 32768, + "17": 32768, + "18": 32768, + "19": 32768, + "20": 32768, + "21": 32768, + "22": 32768, + "23": 32768, + "24": 32768, + "25": 32768, + "26": 32768 + } + }`, + sourceType: SourceType.navigation, + parseNamedBudgets: true, + expectedErrors: [ + { + msg: 'exceeds the maximum number of keys (25)', + path: ['named_budgets'], + }, + ], + }, + { + name: 'named-budget-non-positive', + input: `{ + "destination": "https://a.test", + "named_budgets": { + "1": 32768, + "2": 0, + "3": -1 + } + }`, + sourceType: SourceType.navigation, + parseNamedBudgets: true, + expectedErrors: [ + { + msg: 'must be in the range [0, 65536]', + path: ['named_budgets', '3'], + }, + ], + }, + { + name: 'named-budget-exceeds-max', + input: `{ + "destination": "https://a.test", + "named_budgets": { + "1": 65537 + } + }`, + sourceType: SourceType.navigation, + parseNamedBudgets: true, + expectedErrors: [ + { + msg: 'must be in the range [0, 65536]', + path: ['named_budgets', '1'], + }, + ], + }, + { + name: 'named-budget-not-integer', + input: `{ + "destination": "https://a.test", + "named_budgets": { + "1": "1024" + } + }`, + sourceType: SourceType.navigation, + parseNamedBudgets: true, + expectedErrors: [ + { + msg: 'must be a number', + path: ['named_budgets', '1'], + }, + ], + }, ] testCases.forEach((tc) => @@ -3075,6 +3224,7 @@ testCases.forEach((tc) => sourceType: tc.sourceType ?? SourceType.navigation, fullFlex: tc.parseFullFlex, noteInfoGain: tc.noteInfoGain, + namedBudgets: tc.parseNamedBudgets, }) ) ) diff --git a/ts/src/header-validator/source.ts b/ts/src/header-validator/source.ts index 68b330a5bd..7eeb69fcf4 100644 --- a/ts/src/header-validator/source.ts +++ b/ts/src/header-validator/source.ts @@ -8,6 +8,7 @@ export type EventReportWindows = { export type FilterData = Map> export type AggregationKeys = Map +export type NamedBudgets = Map export enum SummaryOperator { count = 'count', @@ -41,6 +42,7 @@ export type Source = reg.CommonDebug & reg.Priority & { aggregatableReportWindow: number aggregationKeys: AggregationKeys + namedBudgets: NamedBudgets destination: Set expiry: number filterData: FilterData diff --git a/ts/src/header-validator/to-json.ts b/ts/src/header-validator/to-json.ts index 18f3abb63d..cd0801105c 100644 --- a/ts/src/header-validator/to-json.ts +++ b/ts/src/header-validator/to-json.ts @@ -190,6 +190,7 @@ type Source = CommonDebug & Priority & (NotFullFlexSource | FullFlexSource) & { aggregation_keys: { [key: string]: string } + named_budgets?: { [key: string]: number } aggregatable_report_window: number destination: string[] destination_limit_priority: string @@ -205,6 +206,7 @@ type Source = CommonDebug & export interface Options { fullFlex?: boolean | undefined + namedBudgets?: boolean | undefined } export function serializeSource( @@ -246,6 +248,9 @@ export function serializeSource( ...ifNotNull('attribution_scopes', s.attributionScopes, (v) => serializeAttributionScopes(v) ), + ...(opts.namedBudgets && { + named_budgets: Object.fromEntries(s.namedBudgets), + }), } return stringify(source) @@ -314,6 +319,9 @@ function serializeEventTriggerDatum( } type AggregatableDedupKey = FilterPair & DedupKey +type NamedBudget = FilterPair & { + name?: string +} function serializeAggregatableDedupKey( d: trigger.AggregatableDedupKey @@ -324,6 +332,13 @@ function serializeAggregatableDedupKey( } } +function serializeNamedBudget(b: trigger.NamedBudget): NamedBudget { + return { + ...serializeFilterPair(b), + ...ifNotNull('name', b.name, (v) => v.toString()), + } +} + type AggregatableTriggerDatum = FilterPair & KeyPiece & { source_keys: string[] @@ -370,6 +385,7 @@ function serializeAggregatableValuesConfiguration( type Trigger = CommonDebug & FilterPair & { aggregatable_deduplication_keys: AggregatableDedupKey[] + named_budgets?: NamedBudget[] aggregatable_source_registration_time: string aggregatable_trigger_data: AggregatableTriggerDatum[] aggregatable_filtering_id_max_bytes: number @@ -394,6 +410,10 @@ export function serializeTrigger( serializeAggregatableDedupKey ), + ...(opts.namedBudgets && { + named_budgets: Array.from(t.namedBudgets, serializeNamedBudget), + }), + aggregatable_source_registration_time: t.aggregatableSourceRegistrationTime, aggregatable_trigger_data: Array.from( diff --git a/ts/src/header-validator/trigger.test.ts b/ts/src/header-validator/trigger.test.ts index aaf33066f3..cfb8b9a391 100644 --- a/ts/src/header-validator/trigger.test.ts +++ b/ts/src/header-validator/trigger.test.ts @@ -19,6 +19,11 @@ const testCases: jsontest.TestCase[] = [ "filters": {"x": []}, "not_filters": {"y": []} }], + "named_budgets": [{ + "name": "1", + "filters": {"x": []}, + "not_filters": {"y": []} + }], "aggregatable_source_registration_time": "include", "aggregatable_trigger_data": [{ "filters": {"a": ["b"]}, @@ -49,6 +54,7 @@ const testCases: jsontest.TestCase[] = [ }, "attribution_scopes": ["1"] }`, + parseNamedBudgets: true, expected: Maybe.some({ aggregatableDedupKeys: [ { @@ -67,6 +73,23 @@ const testCases: jsontest.TestCase[] = [ ], }, ], + namedBudgets: [ + { + name: '1', + positive: [ + { + lookbackWindow: null, + map: new Map([['x', new Set()]]), + }, + ], + negative: [ + { + lookbackWindow: null, + map: new Map([['y', new Set()]]), + }, + ], + }, + ], aggregatableSourceRegistrationTime: AggregatableSourceRegistrationTime.include, aggregationCoordinatorOrigin: @@ -1693,6 +1716,51 @@ const testCases: jsontest.TestCase[] = [ }, ], }, + + // Named budgets. + { + name: 'named-budgets-wrong-type', + input: `{"named_budgets": 1}`, + parseNamedBudgets: true, + expectedErrors: [ + { + path: ['named_budgets'], + msg: 'must be a list', + }, + ], + }, + { + name: 'named-budgets-value-wrong-type', + input: `{"named_budgets": [1]}`, + parseNamedBudgets: true, + expectedErrors: [ + { + path: ['named_budgets', 0], + msg: 'must be an object', + }, + ], + }, + { + name: 'named-budget-wrong-type', + input: `{"named_budgets": [{ + "name": 1 + }]}`, + parseNamedBudgets: true, + expectedErrors: [ + { + path: ['named_budgets', 0, 'name'], + msg: 'must be a string', + }, + ], + }, + { + name: 'named_budget-missing-name', + input: `{"named_budgets": [{ + "filters": [], + "not_filters": [] + }]}`, + parseNamedBudgets: true, + }, ] testCases.forEach((tc) => @@ -1701,6 +1769,7 @@ testCases.forEach((tc) => trigger.validator({ vsv: { ...vsv.Chromium, ...tc.vsv }, fullFlex: tc.parseFullFlex, + namedBudgets: tc.parseNamedBudgets, }) ) ) diff --git a/ts/src/header-validator/trigger.ts b/ts/src/header-validator/trigger.ts index 13deda568a..7ccdbe4e63 100644 --- a/ts/src/header-validator/trigger.ts +++ b/ts/src/header-validator/trigger.ts @@ -39,6 +39,12 @@ export type EventTriggerDatum = FilterPair & export type AggregatableDedupKey = FilterPair & DedupKey +export type BudgetName = { + name: string | null +} + +export type NamedBudget = FilterPair & BudgetName + export enum AggregatableSourceRegistrationTime { exclude = 'exclude', include = 'include', @@ -48,6 +54,7 @@ export type Trigger = reg.CommonDebug & FilterPair & reg.AggregationCoordinatorOrigin & { aggregatableDedupKeys: AggregatableDedupKey[] + namedBudgets: NamedBudget[] aggregatableTriggerData: AggregatableTriggerDatum[] aggregatableSourceRegistrationTime: AggregatableSourceRegistrationTime aggregatableFilteringIdMaxBytes: number diff --git a/ts/src/header-validator/validate-json.test.ts b/ts/src/header-validator/validate-json.test.ts index f3cecf87d6..a17d961083 100644 --- a/ts/src/header-validator/validate-json.test.ts +++ b/ts/src/header-validator/validate-json.test.ts @@ -4,4 +4,5 @@ import * as vsv from '../vendor-specific-values' export type TestCase = testutil.TestCase & { vsv?: Readonly> parseFullFlex?: boolean + parseNamedBudgets?: boolean } diff --git a/ts/src/header-validator/validate-json.ts b/ts/src/header-validator/validate-json.ts index 21eaf7850d..9869f3f865 100644 --- a/ts/src/header-validator/validate-json.ts +++ b/ts/src/header-validator/validate-json.ts @@ -38,6 +38,7 @@ export const UINT32_MAX: number = 2 ** 32 - 1 export interface RegistrationOptions { vsv: VendorSpecificValues fullFlex?: boolean | undefined + namedBudgets?: boolean | undefined } export class RegistrationContext< diff --git a/ts/src/header-validator/validate-source.ts b/ts/src/header-validator/validate-source.ts index c27e793ce4..8ba9f03d25 100644 --- a/ts/src/header-validator/validate-source.ts +++ b/ts/src/header-validator/validate-source.ts @@ -3,6 +3,7 @@ import { SourceType } from '../source-type' import * as context from './context' import { Maybe } from './maybe' import { + NamedBudgets, AggregationKeys, AttributionScopes, EventReportWindows, @@ -237,6 +238,26 @@ function aggregationKeys(j: Json, ctx: Context): Maybe { ) } +function namedBudgetValue(j: Json, ctx: Context): Maybe { + return number(j, ctx) + .filter(isInteger, ctx) + .filter(isInRange, ctx, 0, constants.allowedAggregatableBudgetPerSource) +} + +function namedBudget([name, j]: [string, Json], ctx: Context): Maybe { + if (name.length > constants.maxLengthPerBudgetName) { + ctx.error( + `name exceeds max length per budget name (${name.length} > ${constants.maxLengthPerBudgetName})` + ) + return Maybe.None + } + return namedBudgetValue(j, ctx) +} + +function namedBudgets(j: Json, ctx: Context): Maybe { + return keyValues(j, ctx, namedBudget, constants.maxNamedBudgetsPerSource) +} + function roundAwayFromZeroToNearestDay(n: number): number { if (n <= 0 || !Number.isInteger(n)) { throw new RangeError() @@ -728,6 +749,9 @@ function source(j: Json, ctx: Context): Maybe { 'attribution_scopes', withDefault(attributionScopes, null) ), + namedBudgets: ctx.opts.namedBudgets + ? field('named_budgets', withDefault(namedBudgets, new Map())) + : () => Maybe.some(new Map()), ...commonDebugFields, ...priorityField, diff --git a/ts/src/header-validator/validate-trigger.ts b/ts/src/header-validator/validate-trigger.ts index 9c77098b0b..4789897695 100644 --- a/ts/src/header-validator/validate-trigger.ts +++ b/ts/src/header-validator/validate-trigger.ts @@ -10,10 +10,12 @@ import { AggregatableValues, AggregatableValuesConfiguration, AggregatableValuesValue, + BudgetName, DedupKey, EventTriggerDatum, FilterConfig, FilterPair, + NamedBudget, Trigger, } from './trigger' import { isInteger, isInRange, required, withDefault } from './validate' @@ -106,6 +108,10 @@ const dedupKeyField: StructFields = { dedupKey: field('deduplication_key', withDefault(uint64, null)), } +const nameField: StructFields = { + name: field('name', withDefault(string, null)), +} + function sourceKeys(j: Json, ctx: Context): Maybe> { return set(j, ctx, string) } @@ -273,6 +279,15 @@ function aggregatableDedupKeys( ) } +function namedBudgets(j: Json, ctx: Context): Maybe { + return array(j, ctx, (j) => + struct(j, ctx, { + ...nameField, + ...filterFields, + }) + ) +} + function warnInconsistentAggregatableKeys(t: Trigger, ctx: Context): void { const allAggregatableValueKeys = new Set() for (const cfg of t.aggregatableValuesConfigurations) { @@ -377,6 +392,9 @@ function trigger(j: Json, ctx: Context): Maybe { 'aggregatable_deduplication_keys', withDefault(aggregatableDedupKeys, []) ), + namedBudgets: ctx.opts.namedBudgets + ? field('named_budgets', withDefault(namedBudgets, [])) + : () => Maybe.some([]), aggregatableSourceRegistrationTime: () => aggregatableSourceRegTimeVal, eventTriggerData: field( 'event_trigger_data', diff --git a/verbose_debugging_reports.md b/verbose_debugging_reports.md index 74109c9ec2..81968ddc57 100644 --- a/verbose_debugging_reports.md +++ b/verbose_debugging_reports.md @@ -181,6 +181,13 @@ had [insufficient budget][]. Additional fields: `limit` +#### `trigger-aggregate-insufficient-named-budget` + +Aggregatable attribution for the trigger failed because the attributed source +had [insufficient named budget][]. + +Additional fields: `name`, `limit` + #### `trigger-aggregate-no-contributions` Aggregatable attribution for the trigger failed because no @@ -288,6 +295,7 @@ The trigger was rejected due to an internal error. [event-level report body]: https://github.com/WICG/attribution-reporting-api/blob/main/EVENT.md#attribution-reports [filter data]: https://github.com/WICG/attribution-reporting-api/blob/main/EVENT.md#optional-attribution-filters [insufficient budget]: https://github.com/WICG/attribution-reporting-api/blob/main/AGGREGATE.md#contribution-bounding-and-budgeting +[insufficient named budget]: https://github.com/WICG/attribution-reporting-api/blob/main/AGGREGATE.md#optional-named-budgets [max aggregatable reports]: https://github.com/WICG/attribution-reporting-api/blob/main/AGGREGATE.md#hide-the-true-number-of-attribution-reports [max attributions rate limit]: https://github.com/WICG/attribution-reporting-api/blob/main/EVENT.md#reporting-cooldown--rate-limits [noise]: https://github.com/WICG/attribution-reporting-api/blob/main/EVENT.md#data-limits-and-noise