-
Notifications
You must be signed in to change notification settings - Fork 234
/
spec.bs
704 lines (579 loc) · 54.5 KB
/
spec.bs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
<pre class='metadata'>
Title: Topics API
Status: UD
ED: https://github.com/patcg-individual-drafts/topics
Shortname: topics
Level: 1
URL: https://github.com/patcg-individual-drafts/topics
Editor: Yao Xiao, Google, [email protected]
Editor: Josh Karlin, Google, [email protected]
Abstract: This specification describes a method that could enable ad-targeting based on a person's general browsing interests without exposing their exact browsing history.
!Participate: <a href="https://github.com/patcg-individual-drafts/topics">GitHub patcg-individual-drafts/topics</a> (<a href="https://github.com/patcg-individual-drafts/topics/issues/new">new issue</a>, <a href="https://github.com/patcg-individual-drafts/topics/issues?state=open">open issues</a>)
Group: patcg-id
Repository: patcg-individual-drafts/topics
Markup Shorthands: markdown yes
</pre>
<pre class=link-defaults>
spec:html; type:attribute; text:document
spec:webidl; type:dfn; text:resolve
spec:dom; type:dfn; text:origin
spec:fetch; type:dfn; for:/; text:request
spec:dom; type:attribute; text:URL
spec:infra; type:dfn; text:user agent
spec:fetch; type:dfn; text:HTTP-network-or-cache fetch
spec:url; type:dfn; for:url; text:host
spec:url; type:dfn; for:url; text:origin
spec:url; type:dfn; text:domain
spec:fetch; type:dfn; for:/; text:header list
spec:fetch; type:dfn; for:Response; text:response
spec:infra; type:dfn; text:list
</pre>
<pre class="anchors">
spec: html; urlPrefix: https://html.spec.whatwg.org/multipage/
type: dfn
text: node navigable; url: document-sequences.html#node-navigable
text: relevant settings object; url: webappapis.html#relevant-settings-object
text: top-level traversable; for:navigable; url: document-sequences.html#nav-top
text: active document; for:navigable; url: document-sequences.html#nav-document
text: navigable; for: Window; url: nav-history-apis.html#window-navigable
spec: html; urlPrefix: https://wicg.github.io/nav-speculation/
type: dfn
text: prerendering navigable; url: prerendering.html#prerendering-navigable
text: post-prerendering activation steps list; url: prerendering.html#platform-object-post-prerendering-activation-steps-list
spec: html; urlPrefix: https://www.rfc-editor.org/rfc/
type: dfn
text: HMAC algorithm; url: rfc6234#section-8.3
spec: html; urlPrefix: https://www.rfc-editor.org/rfc/
type: dfn
text: Structured Fields Token; url: rfc8941.html#name-tokens
type: dfn
text: Structured Fields Parameters; url: rfc8941.html#name-parameters
</pre>
<style>
/* .XXX from https://resources.whatwg.org/standard.css */
.XXX {
color: #D50606;
background: white;
border: solid #D50606;
}
</style>
<section>
<h2 id="introduction-header">Introduction</h2>
On today's web, people's interests are typically inferred based on observing what sites or pages they visit. This relies on tracking techniques such as third-party cookies, or less-transparent mechanisms like device fingerprinting. It would be better for privacy if interest-based advertising could be accomplished without needing to collect a particular individual's browsing history.
This specification provides an API to enable ad-targeting based on a person's general browsing interests, without exposing their exact browsing history.
<div class="example">
Creating an ad based on browsing interests, using the {{Document/browsingTopics()|document.browsingTopics()}} JavaScript API:
(Inside an `https://ads.example` iframe)
<pre class="lang-js">
// document.browsingTopics() returns an array of BrowsingTopic objects.
const topics = await document.browsingTopics();
// Get data for an ad creative.
const response = await fetch('https://ads.example/get-creative', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(topics)
});
// Get the JSON from the response.
const creative = await response.json();
// Display the ad.
</pre>
</div>
<div class="example">
Creating an ad based on browsing interests, based on the [:Sec-Browsing-Topics:] HTTP request header sent by this invocation of {{WindowOrWorkerGlobalScope/fetch()}}:
(Inside the top level context)
<pre class="lang-js">
// A 'Sec-Browsing-Topics: [topics header value]' header will be sent in
// the HTTP request.
const response = await fetch('https://ads.example/get-creative', {browsingTopics: true});
const adCreative = await response.json();
// Display the ad.
</pre>
</div>
</section>
<section>
<h2 id="terminology-and-types-header">Terminology and types</h2>
A <dfn for="browsing topics types">taxonomy</dfn> comprises a list of advertising <dfn for="browsing topics types">topic ids</dfn> as integers. A [=browsing topics types/taxonomy=] is identified by a <dfn for="browsing topics types">taxonomy version</dfn> string. A [=browsing topics types/topic id=] is no smaller than 1.
The taxonomy must be in a tree hierarchy, where an ancestor [=browsing topics types/topic id=] always represents something more general than its descendant [=browsing topics types/topic ids=]. The browser should implement a <dfn>get descendant topics</dfn> algorithm, which takes a [=browsing topics types/topic id=], and returns its descendants' [=browsing topics types/topic ids=] as a [=list=].
The <dfn for="browsing topics types">model version</dfn> is a string that identifies the <dfn for="browsing topics types">model</dfn> used to <dfn>classify</dfn> a string into [=topic ids=]. The meaning may vary across browser vendors. The classification result [=topic ids=] should be relevant to the input string's underlying content.
The <dfn for="browsing topics types">configuration version</dfn> identifies the algorithm (other than the model part) used to calculate the topic. It should take the form of "<code><browser vendor identifier>.<an integer version></code>". The meaning may vary across browser vendors.
Given [=browsing topics types/configuration version=] |configurationVersion|, [=browsing topics types/taxonomy version=] |taxonomyVersion|, and [=browsing topics types/model version=] |modelVersion|, the <dfn for="browsing topics types">version</dfn> is the result of [=string/concatenating=] « |configurationVersion|, |taxonomyVersion|, |modelVersion| » using "<code>:</code>".
The <dfn for="browsing topics types">maximum version string length</dfn> is the maximum possible string length of a [=browsing topics types/version=] that a user agent could possibly generate in a given software release. For example, in Chrome's experimentation phase, 13 was used for the [=browsing topics types/maximum version string length=] to account for a version like <code>chrome.1:1:11</code>.
A <dfn for="browsing topics types">user topics state</dfn> is a struct with the following fields and default values:
- <dfn for="user topics state">epochs</dfn>: a list of [=epoch=]s, default to an empty list.
- <dfn for="user topics state">hmac key</dfn>: 128-bit number, default to 0.
An <dfn for="browsing topics types">epoch</dfn> is a struct with the following fields:
- <dfn for="epoch">taxonomy</dfn>: a list of integers.
- <dfn for="epoch">taxonomy version</dfn>: a string.
- <dfn for="epoch">model version</dfn>: a string.
- <dfn for="epoch">config version</dfn>: a string.
- <dfn for="epoch">top 5 topics with caller domains</dfn>: a list of [=topic with caller domains=].
- <dfn for="epoch">time</dfn>: a {{DOMHighResTimeStamp}} (from Unix epoch).
A <dfn for="browsing topics types">topic with caller domains</dfn> is a struct with the following fields:
- <dfn for="topic with caller domains">topic id</dfn>: an integer.
- <dfn for="topic with caller domains">caller domains</dfn>: a set of [=domains=].
A <dfn for="browsing topics types">topics history entry</dfn> is a struct with the following fields and default values:
- <dfn for="topics history entry">document id</dfn>: an integer, default to 0.
- <dfn for="topics history entry">topics calculation input data</dfn>: a string, default to an empty string.
- <dfn for="topics history entry">time</dfn>: a {{DOMHighResTimeStamp}} (from Unix epoch).
- <dfn for="topics history entry">topics caller domains</dfn>: an ordered set of [=domains=], default to an empty set.
A <dfn for="browsing topics types">topics caller context</dfn> is a struct with the following fields:
- <dfn for="topics caller context">caller domain</dfn>: a [=domain=].
- <dfn for="topics caller context">top level context domain</dfn>: a [=domain=].
- <dfn for="topics caller context">timestamp</dfn>: a {{DOMHighResTimeStamp}} (from Unix epoch).
<div class="note">
All [=domains=] used in this API will be the result of obtaining the [=registrable domain=] from some [=host=].
</div>
<h2 id="user-agent-associated-state-header">User agent associated state</h2>
Each [=user agent=] has an associated [=browsing topics types/user topics state=] <dfn for="user agent">user topics state</dfn> with [=user topics state/epochs=] initially empty, and [=user topics state/hmac key=] initially a randomly generated 128-bit number.
Each [=user agent=] has an associated <dfn for="user agent">topics history storage</dfn> to store the information about the visited pages that are needed for topics calculation. It is a [=list=] of [=topics history entries=], initially empty.
Each [=user agent=] has an associated [=browsing topics types/taxonomy=] <dfn for="user agent">taxonomy</dfn> (identified by [=browsing topics types/taxonomy version=] <dfn for="user agent">taxonomy version</dfn>) and [=browsing topics types/model=] <dfn for="user agent">model</dfn> (identified by [=browsing topics types/model version=] <dfn for="user agent">model version</dfn>).
The [=user agent/taxonomy=] and [=user agent/model=] may be shipped to the browser asynchronously with respect to the browser release, and may be unavailable at a given point. They must be updated atomically with respect to algorithms that access them (e.g. the [=calculate user topics=] algorithm).
Note: In Chrome versions M121 and later, the taxonomy used is <a href="https://github.com/patcg-individual-drafts/topics/blob/main/taxonomy_v2.md">taxonomy_v2.md</a>. The expectation is that it will change over time.
Each [=user agent=] has an associated topics algorithm configuration (identified by [=browsing topics types/configuration version=] <dfn for="user agent">configuration version</dfn>). The initial value and meaning is browser defined.
Note: The [=browsing topics types/configuration version=] allows the browser vendor to provide algorithms different from the ones specified in this specification. For example, for some of the algorithms in this specification, it may be possible to use a different constant value, while the system overall still has utility and <a href="#privacy-considerations-header">meets the privacy goals</a>.
When the [=user agent/configuration version=] is updated, the browser must properly migrate or delete data in [=user agent/user topics state=] and [=user agent/topics history storage=] so that the state and the configuration are consistent.
<h3 id="expiring-stored-data">Expiring stored data</h3>
User agents must automatically delete stored data 28 days after its creation.
<h2 id="browsing-topic-dictionary-header">BrowsingTopic dictionary</h2>
The {{BrowsingTopic}} dictionary is used to contain the IDL correspondences of [=browsing topics types/topic id=], [=browsing topics types/version=], [=browsing topics types/configuration version=], [=browsing topics types/taxonomy version=], and [=browsing topics types/model version=].
<pre class="idl">
dictionary BrowsingTopic {
[EnforceRange] unsigned long long topic;
DOMString version;
DOMString configVersion;
DOMString modelVersion;
DOMString taxonomyVersion;
};
</pre>
<div class="example">
An example {{BrowsingTopic}} object from Chrome: <code highlight="js">{ configVersion: "chrome.1", modelVersion: "1", taxonomyVersion: "1", topic: 43, version: "chrome.1:1:1" }</code>.
</div>
<div algorithm>
A {{BrowsingTopic}} dictionary |a| is <dfn id=browsing-topics-dictionary-less-than-comparator for="browsing-topic">code unit less than</dfn> a {{BrowsingTopic}} dictionary |b| if the following steps return true:
1. If |a|["{{BrowsingTopic/version}}"] is [=/code unit less than=] |b|["{{BrowsingTopic/version}}"], then return true.
1. If |a|["{{BrowsingTopic/topic}}"] < |b|["{{BrowsingTopic/topic}}"], then return true.
1. Return false.
</div>
</section>
<section>
<h2 id="document-id-header">document ID</h2>
Each {{Document}} has a <dfn for="document-id-header">document id</dfn>, which is an [=implementation-defined=] unique identifier shared with no other {{Document}} objects within or across browser sessions for a user agent.
<h2 id="determine-topics-calculation-input-data-header">Determine topics calculation input data</h2>
Given a {{Document}}, the browser must have a way to determine the <dfn for="determine-topics-calculation-input-data-header">topics calculation input data</dfn>. [=determine-topics-calculation-input-data-header/topics calculation input data=] is a string that encodes the attributes to be used for topics classification, as determined by the browser vendor. By default, the attributes should be scoped to the document's [=Document/URL=] and <a href="https://html.spec.whatwg.org/multipage/semantics.html#document-metadata">metadata</a>.
Note: unless specifically allowed, data beyond the document shouldn't be included, such as data from localStorage or cookies.
Note: In Chrome's experimentation phase, the [=host=] of a {{Document}}'s [=Document/URL=] is used as the [=determine-topics-calculation-input-data-header/topics calculation input data=], and the model is trained with human curated hostnames and topics.
<h2 id="collect-page-topics-calculation-input-data-header">Collect page topics calculation input data</h2>
<div algorithm>
To <dfn>collect page topics calculation input data</dfn>, given a {{Document}} |document|:
1. If |document|'s [=node navigable=] is a [=prerendering navigable=], then append the following steps to |document|'s [=post-prerendering activation steps list=] and return. Else, run the following steps [=in parallel=]:
1. Let |documentId| be |document|'s [=document-id-header/document id=].
1. If user agent's [=user agent/topics history storage=] contains a [=topics history entry=] whose [=topics history entry/document id=] is |documentId|, return.
1. Let |topicsHistoryEntry| be a [=topics history entry=].
1. Set |topicsHistoryEntry|'s [=topics history entry/document id=] to |documentId|.
1. Set |topicsHistoryEntry|'s [=topics history entry/topics calculation input data=] to the [=determine-topics-calculation-input-data-header/topics calculation input data=] for |document|.
1. Let |unsafeMoment| be the [=wall clock=]'s [=wall clock/unsafe current time=].
1. Let |moment| be the result of running [=coarsen time=] algorithm given |unsafeMoment| and [=wall clock=] as input.
1. Let |fromUnixEpochTime| be the [=duration from=] the [=Unix epoch=] to |moment|.
1. Set |topicsHistoryEntry|'s [=topics history entry/time=] to |fromUnixEpochTime|.
1. [=list/Append=] |topicsHistoryEntry| to user agent's [=user agent/topics history storage=].
</div>
<h2 id="collect-topics-caller-domain-header">Collect topics caller domain</h2>
<div algorithm>
To <dfn>collect topics caller domain</dfn>, given a {{Document}} |document| and a [=domain=] |callerDomain|:
1. Run the following steps [=in parallel=]:
1. Let |documentId| be |document|'s [=document-id-header/document id=].
1. If user agent's [=user agent/topics history storage=] does not contain a [=topics history entry=] whose [=topics history entry/document id=] is |documentId|, return.
1. Let |topicsHistoryEntry| be the [=topics history entry=] in user agent's [=user agent/topics history storage=] whose [=topics history entry/document id=] is |documentId|.
1. [=set/Append=] |callerDomain| to |topicsHistoryEntry|'s [=topics caller domains=].
</div>
<h2 id="derive-top-5-topics-header">Derive top 5 topics</h2>
Given a [=list=] of [=topics history entries=] <var ignore=''>historyEntriesForUserTopics</var>, the browser should provide an algorithm to <dfn>derive top 5 topics</dfn>, that are believed to be valuable for the Topics callers. The algorithm should return a [=list=] of 5 [=topic ids=].
<div class="note">
In Chrome versions M122 and later, topics are scored for ranking first by a binary priority level (see <a href="https://github.com/patcg-individual-drafts/topics/blob/main/topics-utility-buckets-v1.md">topics-utility-buckets-v1.md</a>), and then by the frequency of page loads with that topic.
<div class="example">
<div algorithm="example-algorithm">
Given a [=list=] of [=topics history entries=] |historyEntriesForUserTopics|:
1. Let |topicsCount| be an empty map.
1. For each [=topics history entry=] |historyEntry| in |historyEntriesForUserTopics|:
1. Let |topicIds| be the result of [=classifying=] |historyEntry|'s [=topics history entry/topics calculation input data=].
1. For each |topicId| in |topicIds|:
1. If |topicsCount|[|topicId|] does not exist:
1. Initialize |topicsCount|[|topicId|] to 0.
1. Increment |topicsCount|[|topicId|] by 1.
1. Let |prioritizedTopicsCount| be the result of [=map/sorting in ascending order=] |topicsCount|, with a less than algorithm [=compare topics based on priority and count=].
1. Let |top5Topics| be the first up to 5 keys of |prioritizedTopicsCount|.
1. If |top5Topics| has less than 5 entries:
1. Pad |top5Topics| with random topic ids from user agent's [=user agent/taxonomy=], until |top5Topics| has 5 entries.
1. Return |top5Topics|.
</div>
<div algorithm>
To <dfn>compare topics based on priority and count</dfn>, given (|topic1|, |count1|) and (|topic2|, |count2|), perform the following steps. They return a boolean.
1. [=Assert=]: |count1| > 0.
1. [=Assert=]: |count2| > 0.
1. Let |highUtilityTopics| be « 57, 86, 126, 149, 172, 180, 196, 207, 239, 254, 263, 272, 289, 299, 332 ».
1. If |highUtilityTopics| [=list/contains=] |topic1| and |highUtilityTopics| does not [=list/contain=] |topic2|, then return true.
1. If |highUtilityTopics| does not [=list/contain=] |topic1| and |highUtilityTopics| [=list/contains=] |topic2|, then return false.
1. Return |count1| > |count2|.
</div>
</div>
</div>
<h2 id="periodically-calculate-user-topics-header">Periodically calculate user topics</h2>
At the start of a browser session, run the [=schedule user topics calculation=] algorithm.
<div class="note">
This roughly schedules topic calculation every 7 days, unless the browser is inactive at the scheduled time(s), in which case a topic calculation will occur as soon as the browser restarts.
</div>
<div algorithm>
To <dfn>schedule user topics calculation</dfn>, perform the following steps:
1. Let |unsafeMoment| be the [=wall clock=]'s [=wall clock/unsafe current time=].
1. Let |moment| be the result of running [=coarsen time=] algorithm given |unsafeMoment| and [=wall clock=] as input.
1. Let |fromUnixEpochTime| be the [=duration from=] the [=Unix epoch=] to |moment|.
1. Let |presumedNextCalculationDelay| be a [=duration=] of 0.
1. If user agent's [=user agent/user topics state=]'s [=user topics state/epochs=] is not empty:
1. Let |numEpochs| be user agent's [=user agent/user topics state=]'s [=user topics state/epochs=]'s [=list/size=].
1. Let |lastTopicsCalculationTime| be user agent's [=user agent/user topics state=]'s [=user topics state/epochs=][|numEpochs| − 1].
1. Let |presumedNextCalculationDelay| be |lastTopicsCalculationTime| + (a [=duration=] of 7 days) − |fromUnixEpochTime|.
1. If |presumedNextCalculationDelay| < (a [=duration=] of 0), then set |presumedNextCalculationDelay| to (a [=duration=] of 0).
1. Else if |presumedNextCalculationDelay| ≥ (a [=duration=] of 14 days), then set |presumedNextCalculationDelay| to (a [=duration=] of 0).
Note: This could happen if the machine time has gone backward since the last topics calculation. Recalculate immediately to align with the expected schedule rather than potentially stop calculating for a very long time.
1. Schedule the [=calculate user topics=] algorithm to run at [=Unix epoch=] + |fromUnixEpochTime| + |presumedNextCalculationDelay|.
</div>
<div algorithm>
To <dfn>calculate user topics</dfn>, perform the following steps:
1. Let |unsafeMoment| be the [=wall clock=]'s [=wall clock/unsafe current time=].
1. Let |moment| be the result of running [=coarsen time=] algorithm given |unsafeMoment| and [=wall clock=] as input.
1. Let |fromUnixEpochTime| be the [=duration from=] the [=Unix epoch=] to |moment|.
1. If either user agent's [=user agent/model=] or [=user agent/taxonomy=] isn't available:
1. Let |epoch| be an [=epoch=] struct with default initial field values.
1. Set |epoch|'s [=epoch/time=] to |fromUnixEpochTime|.
1. [=list/Append=] |epoch| to user agent's [=user agent/user topics state=]'s [=user topics state/epochs=].
1. If user agent's [=user agent/user topics state=]'s [=user topics state/epochs=] has more than 4 entries, remove the oldest epoch (i.e. the epoch with index 0).
1. Schedule this [=calculate user topics=] algorithm to run at [=Unix epoch=] + |fromUnixEpochTime| + (a [=duration=] of 7 days).
1. Return.
1. Let |historyEntriesForUserTopics| be an empty list.
1. Let |topicsCallers| be an empty map.
1. Let |userTopicsDataStartTime| be |fromUnixEpochTime| − (a [=duration=] of 7 days).
1. Let |topicsCallerDataStartTime| be |fromUnixEpochTime| − (a [=duration=] of 21 days).
1. For each [=topics history entry=] |topicsHistoryEntry| in user agent's [=user agent/topics history storage=]:
1. Let |visitTime| be |topicsHistoryEntry|'s [=topics history entry/time=].
1. If |visitTime| is before |topicsCallerDataStartTime|, then continue.
1. Let |topicIds| be the result of [=classifying=] |topicsHistoryEntry|'s [=topics history entry/topics calculation input data=].
1. If |visitTime| is greater than |userTopicsDataStartTime|:
1. [=list/Append=] |topicsHistoryEntry| to |historyEntriesForUserTopics|.
1. For each |topicId| in |topicIds|:
1. If |topicsCallers|[|topicId|] does not exist:
1. Initialize |topicsCallers|[|topicId|] to be an empty [=list=].
1. For each |callerDomain| in |topicsHistoryEntry|'s [=topics history entry/topics caller domains=]:
1. [=list/Append=] |callerDomain| to |topicsCallers|[|topicId|].
1. Let |top5Topics| be the result of running [=derive top 5 topics=] algorithm, given |historyEntriesForUserTopics|.
1. Let |top5TopicsWithCallerDomains| be an empty [=list=].
1. For each |topTopicId| in |top5Topics|:
1. Let |topicWithCallerDomains| be a [=topic with caller domains=] struct with [=topic with caller domains/topic id=] initially 0 and [=topic with caller domains/caller domains=] initially empty.
1. If |topTopicId| is allowed by user preference setting:
1. Set |topicWithCallerDomains|'s [=topic with caller domains/topic id=] to |topicId|.
1. Let |topicWithDescendantIds| be the result of running [=get descendant topics=] given |topTopicId|.
1. Add |topTopicId| to |topicWithDescendantIds|.
1. For each |topicId| in |topicWithDescendantIds|:
1. If |topicId| is allowed by user preference setting:
1. Insert all elements in |topicsCallers|[|topicId|] to |topicWithCallerDomains|'s [=topic with caller domains/caller domains=].
1. [=list/Append=] |topicWithCallerDomains| to |top5TopicsWithCallerDomains|.
1. Let |epoch| be an [=epoch=] struct with default initial field values.
1. Set |epoch|'s [=epoch/taxonomy=] to user agent's [=user agent/taxonomy=].
1. Set |epoch|'s [=epoch/taxonomy version=] to user agent's [=user agent/taxonomy version=].
1. Set |epoch|'s [=epoch/model version=] to user agent's [=user agent/model version=].
1. Set |epoch|'s [=epoch/config version=] to user agent's [=user agent/configuration version=].
1. Set |epoch|'s [=epoch/top 5 topics with caller domains=] to |top5TopicsWithCallerDomains|.
1. Set |epoch|'s [=epoch/time=] to |fromUnixEpochTime|.
1. [=list/Append=] |epoch| to user agent's [=user agent/user topics state=]'s [=user topics state/epochs=].
1. If user agent's [=user agent/user topics state=]'s [=user topics state/epochs=] has more than 4 entries, remove the oldest epoch.
1. Schedule this [=calculate user topics=] algorithm to run at [=Unix epoch=] + |fromUnixEpochTime| + (a [=duration=] of 7 days).
</div>
<h2 id="epochs-for-caller-header">Epochs for caller</h2>
<div algorithm>
To <dfn>calculate the epochs for caller</dfn>, given a [=topics caller context=] |callerContext|, perform the following steps. They return a list of [=epoch=]s.
1. Let |epochs| be user agent's [=user agent/user topics state=]'s [=user topics state/epochs=].
1. If |epochs| is empty, then return an empty [=list=].
1. Let |numEpochs| be |epochs|'s [=list/size=].
1. Let |lastEpochTime| be |epochs|[|numEpochs| − 1]'s [=epoch/time=].
1. Let |epochSwitchTimeDecisionMessageArray| be the concatenation of "epoch-switch-time-decision|", |lastEpochTime|, and |callerContext|'s [=topics caller context/top level context domain=].
1. Let |epochSwitchTimeDecisionHmacOutput| be the output of the [=HMAC algorithm=], given input parameters: whichSha=SHA256, key=user agent's [=user agent/user topics state=]'s [=user topics state/hmac key=], and message_array=|epochSwitchTimeDecisionMessageArray|.
1. Let |epochSwitchTimeDecisionHash| be 64-bit truncation of |epochSwitchTimeDecisionHmacOutput|.
1. Let |epochSwitchTimeDelayIntroduction| be a [=duration=] of (|epochSwitchTimeDecisionHash| % 172800) seconds (i.e. 172800 is 2 days in seconds).
1. Let |epochPhaseOutTimeDecisionMessageArray| be the concatenation of "epoch-phase-out-time-decision|", |lastEpochTime|, and |callerContext|'s [=topics caller context/top level context domain=].
1. Let |epochPhaseOutTimeDecisionHmacOutput| be the output of the [=HMAC algorithm=], given input parameters: whichSha=SHA256, key=user agent's [=user agent/user topics state=]'s [=user topics state/hmac key=], and message_array=|epochPhaseOutTimeDecisionMessageArray|.
1. Let |epochPhaseOutTimeDecisionHash| be 64-bit truncation of |epochPhaseOutTimeDecisionHmacOutput|.
1. Let |epochPhaseOutTimeOffset| be a [=duration=] of (|epochPhaseOutTimeDecisionHash| % 172800) seconds (i.e. 172800 is 2 days in seconds).
1. Let |timestamp| be |callerContext|'s [=topics caller context/timestamp=].
1. Let |result| be an empty [=list=].
1. Let |startEpochIndex| be -1.
1. Let |endEpochIndex| be -1.
1. If |timestamp| ≤ |lastEpochTime| + |epochSwitchTimeDelayIntroduction|:
1. Set |startEpochIndex| to max(|numEpochs| − 4, 0).
1. Set |endEpochIndex| to |numEpochs| − 2.
1. Else:
1. Set |startEpochIndex| to max(|numEpochs| − 3, 0).
1. Set |endEpochIndex| to |numEpochs| − 1.
1. If |endEpochIndex| ≥ 0:
1. Let |i| be |startEpochIndex|.
1. Let |epochRetentionDuration| be a [=duration=] of 28 days.
1. While |i| ≤ |endEpochIndex|:
1. If |epochs|[|i|]'s [=epoch/time=] < |timestamp| - |epochRetentionDuration| + |epochPhaseOutTimeOffset|, then continue.
1. [=list/Append=] |epochs|[|i|] to |result|.
1. Set |i| to |i| + 1.
1. Return |result|.
</div>
<div class="note">
This roughly returns 3 recently calculated epochs, either counting back from the last epoch, or from the second to the last epoch, and excludes any epochs that are too old. The new epoch is introduced after a fixed duration (between 0 and 2 days) has elapsed since the epoch's calculation time. Each epoch expires after a longer fixed interval (between 26 and 28 days). Both durations are specific to each user, site, and epoch. This mechanism makes it harder to correlate the same user across sites via the time that topics are changed, or via the time interval between two changes. The HMAC helps to compute the per-user per-site per-epoch delay on the fly, without needing to store extra data for each site or epoch.
</div>
<h2 id="get-the-number-of-distinct-versions-in-epochs-header">Get the number of distinct versions in epochs</h2>
<div algorithm>
To <dfn>get the number of distinct versions in epochs</dfn>, given a [=topics caller context=] |callerContext|, perform the following steps. They return an integer.
1. Let |epochs| be the result of running the [=calculate the epochs for caller=] algorithm given |callerContext| as input.
1. Let |distinctVersions| be an empty set.
1. For each |epoch| in |epochs|:
1. If |epoch|'s [=epoch/taxonomy version=] is empty (implying that the topics calculation for that epoch didn't occur), then continue.
1. Insert tuple (|epoch|'s [=epoch/taxonomy version=], |epoch|'s [=epoch/model version=]) to distinctVersions.
1. Return |distinctVersions|'s [=list/size=].
</div>
<h2 id="topics-for-caller-header">Topics for caller</h2>
<div algorithm>
To <dfn>calculate the topics for caller</dfn>, given a [=topics caller context=] |callerContext|, perform the following steps. They return a list of {{BrowsingTopic}}s.
1. Let |epochs| be the result of running the [=calculate the epochs for caller=] algorithm given |callerContext| as input.
1. Let |result| be an empty [=list=].
1. For each |epoch| in |epochs|:
1. If |epoch|'s [=epoch/top 5 topics with caller domains=] is empty (implying the topics calculation failed for that epoch), then continue.
1. Let |topic| be null.
1. Let |topTopicIndexDecisionMessageArray| be the concatenation of "top-topic-index-decision|", |epoch|'s [=epoch/time=], and |callerContext|'s [=topics caller context/top level context domain=].
1. Let |topTopicIndexDecisionHmacOutput| be the output of the [=HMAC algorithm=], given input parameters: whichSha=SHA256, key=user agent's [=user agent/user topics state=]'s [=user topics state/hmac key=], and message_array=|topTopicIndexDecisionMessageArray|.
1. Let |topTopicIndexDecisionHash| be 64-bit truncation of |topTopicIndexDecisionHmacOutput|.
1. Let |topTopicIndex| be |topTopicIndexDecisionHash| % 5.
1. Let |topTopicWithCallerDomains| be |epoch|'s [=epoch/top 5 topics with caller domains=][|topTopicIndex|].
1. If |topTopicWithCallerDomains|'s [=topic with caller domains/caller domains=] contains |callerContext|'s [=topics caller context/caller domain=]:
1. Set |topic| to an empty {{BrowsingTopic}} dictionary.
1. Set |topic|["{{BrowsingTopic/topic}}"] to |topTopicWithCallerDomains|'s [=topic with caller domains/topic id=].
1. If |topic| is null, or if |topic|'s {{BrowsingTopic/topic}} is 0 (i.e. the candidate topic was cleared), then continue.
1. Let |randomOrTopTopicDecisionMessageArray| be the concatenation of "random-or-top-topic-decision|", |epoch|'s [=epoch/time=], and |callerContext|'s [=topics caller context/top level context domain=].
1. Let |randomOrTopTopicDecisionHmacOutput| be the output of the [=HMAC algorithm=], given input parameters: whichSha=SHA256, key=user agent's [=user agent/user topics state=]'s [=user topics state/hmac key=], and message_array=|randomOrTopTopicDecisionMessageArray|.
1. Let |randomOrTopTopicDecisionHash| be 64-bit truncation of |randomOrTopTopicDecisionHmacOutput|.
1. If |randomOrTopTopicDecisionHash| % 100 < 5:
1. Let |randomTopicIndexDecisionMessageArray| be the concatenation of "random-topic-index-decision|", |epoch|'s [=epoch/time=], and |callerContext|'s [=topics caller context/top level context domain=].
1. Let |randomTopicIndexDecisionHmacOutput| be the output of the [=HMAC algorithm=], given input parameters: whichSha=SHA256, key=user agent's [=user agent/user topics state=]'s [=user topics state/hmac key=], and message_array=|randomTopicIndexDecisionMessageArray|.
1. Let |randomTopicIndexDecisionHash| be 64-bit truncation of |randomTopicIndexDecisionHmacOutput|.
1. Let |randomTopicIndex| be |randomTopicIndexDecisionHash| % |epoch|'s [=epoch/taxonomy=]'s [=list/size=].
1. Set |topic|'s {{BrowsingTopic/topic}} to |epoch|'s [=epoch/taxonomy=][|randomTopicIndex|].
1. Set |topic|["{{BrowsingTopic/configVersion}}"] to |epoch|'s [=epoch/config version=].
1. Set |topic|["{{BrowsingTopic/modelVersion}}"] to |epoch|'s [=epoch/model version=].
1. Set |topic|["{{BrowsingTopic/taxonomyVersion}}"] to |epoch|'s [=epoch/taxonomy version=].
1. Determine the [=browsing topics types/version=] |version|, given |topic|'s {{BrowsingTopic/configVersion}}, {{BrowsingTopic/modelVersion}} and {{BrowsingTopic/taxonomyVersion}} as input.
1. Set |topic|["{{BrowsingTopic/version}}"] to |version|.
1. Add |topic| to |result|.
1. Sort entries in |result| given the <a href="#browsing-topics-dictionary-less-than-comparator">less-than comparator</a> for the {{BrowsingTopic}} dictionary.
1. Remove duplicate entries in |result|. Two {{BrowsingTopic}} dictionaries |a| and |b| are considered equal if |a| is not [=browsing-topic/code unit less than=] |b| and |b| is not [=browsing-topic/code unit less than=] |a|.
1. Return |result|.
</div>
<div class="note">
This roughly selects one random topic from each of the previous epochs (to limit cross-site reidentification capabilities), and only returns those that were observed by the caller (so that this provides roughly only a subset of the capabilities of third-party cookies). For each epoch, there is a 5% chance to return a random topic from the full taxonomy, rather than returning the real top topic, so as to provide some amount of plausible deniability. This random topic will only be returned if the caller would have received the real top topic (i.e. observed by the caller). This makes it non-trivial to detect which topics are the random topics (see <a href="https://github.com/patcg-individual-drafts/topics/issues/75">github issue</a>). All the randomnesses involved in this process are sticky to the user agent, epoch, and site. The HMAC helps to compute the random sticky values on the fly, without needing to store extra data for each epoch and site.
</div>
</section>
<section>
<h2 id="the-javascript-api-header">The JavaScript API</h2>
The Topics API lives under the {{Document}} interface, and is only available if the document is in [=secure context=].
<pre class="idl">
dictionary BrowsingTopicsOptions {
boolean skipObservation = false;
};
partial interface Document {
[SecureContext] Promise<sequence<BrowsingTopic>> browsingTopics(optional BrowsingTopicsOptions options = {});
};
</pre>
<div algorithm="browsingTopics(options)">
The <dfn for="Document" method>browsingTopics(options)</dfn> method steps are:
1. Let |document| be [=this=].
1. Let |topLevelDocument| be |document|'s [=node navigable=]'s [=navigable/top-level traversable=]'s [=navigable/active document=].
1. Let |promise| be [=a new promise=].
1. Let |topicsCallerContext| be a [=topics caller context=].
1. Set |topicsCallerContext|'s [=topics caller context/caller domain=] to |document|'s [=Document/origin=]'s [=origin/host=]'s [=host/registrable domain=].
1. Set |topicsCallerContext|'s [=topics caller context/top level context domain=] to |topLevelDocument|'s [=Document/origin=]'s [=origin/host=]'s [=host/registrable domain=].
1. Let |unsafeMoment| be the [=wall clock=]'s [=wall clock/unsafe current time=].
1. Let |moment| be the result of running [=coarsen time=] algorithm given |unsafeMoment| and [=wall clock=] as input.
1. Let |fromUnixEpochTime| be the [=duration from=] the [=Unix epoch=] to |moment|.
1. Set |topicsCallerContext|'s [=topics caller context/timestamp=] to |fromUnixEpochTime|.
1. If any of the following is true:
- |document|'s [=Document/origin=] is an [=opaque origin=].
- |document| is not [=allowed to use=] the <code><a href="#browsing-topics-policy-controlled-feature">browsing-topics</a></code> feature.
- |document| is not [=allowed to use=] the <code><a href="#interest-cohort-policy-controlled-feature">interest-cohort</a></code> feature.
then:
1. [=Queue a global task=] on the <dfn>browsing topics task source</dfn> given |document|'s [=relevant global object=] to [=reject=] |promise| with a "{{NotAllowedError}}" {{DOMException}}.
1. Abort these steps.
1. Run the following steps [=in parallel=]:
1. Let |topics| be an empty [=list=].
1. If the user preference setting and other user agent-defined mechanisms like <a href="https://github.com/privacysandbox/attestation">enrollment</a> allow access to topics from |topLevelDocument| given |document|'s [=Document/origin=]:
1. Set |topics| to the result of running the [=calculate the topics for caller=] algorithm, with |topicsCallerContext| as input.
1. If <var ignore=''>options</var>["{{BrowsingTopicsOptions/skipObservation}}"] is false:
1. Run the [=collect page topics calculation input data=] algorithm with |topLevelDocument| as input.
1. Run the [=collect topics caller domain=] algorithm with |topLevelDocument| and |topicsCallerContext|'s [=topics caller context/caller domain=] as input.
1. [=Queue a global task=] on the [=browsing topics task source=] given |document|'s [=relevant global object=] to perform the following steps:
1. [=Resolve=] |promise| with |topics|.
1. Return |promise|.
</div>
</section>
<section>
<h2 id="fetch-and-iframe-integration-header">fetch() and iframe integration</h2>
Topics can be sent in the HTTP header for {{WindowOrWorkerGlobalScope/fetch()}} requests and for <a href="https://html.spec.whatwg.org/multipage/iframe-embed-object.html#navigate-an-iframe-or-frame">iframe navigation</a> requests. The response header for a topics related request can specify whether the caller should be recorded.
<h3 id="send-browsing-topics-header-boolean-associated-with-request-header">send browsing topics header boolean associated with Request</h3>
A [=request=] has an associated <dfn for=request>send browsing topics header boolean</dfn>. Unless stated otherwise it is false.
<span class=XXX>TODO: make the modification directly to the fetch spec.</span>
<h3 id="browsing-topics-content-attribute-for-iframe-element-header">browsingtopics content attribute for HTMLIframeElement</h3>
The <a href="https://html.spec.whatwg.org/multipage/iframe-embed-object.html#the-iframe-element">iframe</a> element contains a <dfn element-attr for="iframe">browsingtopics</dfn> <a spec=html>content attribute</a>. The IDL attribute <dfn attribute for="HTMLIFrameElement">browsingTopics</dfn> <a spec=html>reflects</a> the <{iframe/browsingtopics}> <a spec=html>content attribute</a>.
<pre class=idl>
partial interface HTMLIFrameElement {
[CEReactions] attribute boolean browsingTopics;
};
</pre>
<span class=XXX>TODO: make the modification directly to the html spec.</span>
<h3 id="browsing-topics-attribute-in-request-init-header">browsingTopics attribute in RequestInit</h3>
The <a href="https://fetch.spec.whatwg.org/#requestinit">RequestInit</a> dictionary contains a browsingTopics attribute:
<pre class=idl>
partial dictionary RequestInit {
boolean browsingTopics;
};
</pre>
<span class=XXX>TODO: make the modification directly to the fetch spec.</span>
<h3 id="modification-to-request-constructor-steps-header">Modification to request constructor steps</h3>
The following step will be added to the <a href="https://fetch.spec.whatwg.org/#dom-request">new Request(input, init) constructor steps</a>, before step "Set this's [=Request/request=] to |request|":
1. If <var ignore=''>init</var>["{{RequestInit/browsingTopics}}"] <a for=map>exists</a>, then set |request|'s [=request/send browsing topics header boolean=] to it.
<span class=XXX>TODO: make the modification directly to the fetch spec.</span>
<h3 id="modification-to-create-navigation-params-by-fetching-steps-header">Modification to "create navigation params by fetching" steps</h3>
The following step will be added to the <a href="https://html.spec.whatwg.org/multipage/browsing-the-web.html#create-navigation-params-by-fetching">create navigation params by fetching steps</a>, after step "Let |request| be a new [=Request/request=], with ...":
1. If <var ignore=''>navigable</var>'s [=container=] is an <{iframe}> element, and if it has a <{iframe/browsingtopics}> <a spec=html>content attribute</a>, then set |request|'s [=request/send browsing topics header boolean=] to true.
<span class=XXX>TODO: make the modification directly to the html spec.</span>
<h3 id="the-sec-browsing-topics-http-request-header-header">The \`<code>Sec-Browsing-Topics</code>\` HTTP request header</h3>
This specification defines a \`<dfn export http-header><code>Sec-Browsing-Topics</code></dfn>\` HTTP request header. It is used to send the topics.
<h3 id="modification-to-http-network-or-cache-fetch-algorithm-header">Modification to HTTP-network-or-cache fetch algorithm</h3>
The following step will be added to the <a href="https://fetch.spec.whatwg.org/#concept-http-network-or-cache-fetch">HTTP-network-or-cache fetch</a> algorithm, before step "Modify |httpRequest|'s [=request/header list=] per HTTP. ...":
1. <a>Append or modify a request \`<code>Sec-Browsing-Topics</code>\` header</a> for |httpRequest|.
<span class=XXX>TODO: make the modification directly to the fetch spec.</span>
<h3 id="append-or-modify-a-request-sec-browsing-topics-header-header">Append or modify a request `Sec-Browsing-Topics` header</h3>
<div algorithm>
To <dfn>append or modify a request \`<code>Sec-Browsing-Topics</code>\` header</dfn>, given a [=request=] |request|, run these steps:
1. If |request|'s [=request/send browsing topics header boolean=] is not true, then return.
1. [=header list/Delete=] [:Sec-Browsing-Topics:] from |request|'s [=header list=].
<p class="note">
The topics a request is allowed to see can change within its redirect chain. For example, different caller domains may receive different topics, as the callers can only get the topics about the sites they were on. The timestamp can also affect the candidate epochs where the topics are derived from, thus resulting in different topics across redirects.
</p>
1. Let |initiatorWindow| be |request|'s [=request/window=].
1. Let |requestOrigin| be |request|'s [=request/URL=]'s [=url/origin=].
1. If |requestOrigin| is not a [=potentially trustworthy origin=], then return.
1. If |initiatorWindow| is not an [=environment settings object=], then return.
1. If |initiatorWindow| is not a [=secure context=], then return.
1. For each feature |f| in « "<code>browsing-topic</code>", "<code>interest-cohort</code>" »:
1. Run the <a href="https://www.w3.org/TR/permissions-policy-1/#algo-should-request-be-allowed-to-use-feature">Should request be allowed to use feature?</a> algorithm with <var ignore=''>feature</var> set to |f| and <var ignore=''>request</var> set to |request|. If the algorithm returns false, then return.
Note: the above algorithm should include the <a href="https://github.com/w3c/webappsec-permissions-policy/pull/499">pending update</a>, i.e. the |request| should be considered to contain the equivalent opt-in flags for both "browsing-topic" and the "interest-cohort" feature.
1. Let |topLevelDocument| be |initiatorWindow|'s [=environment settings object/global object=]'s [=Window/navigable=]'s [=navigable/top-level traversable=]'s [=navigable/active document=].
1. Let |topicsCallerContext| be a [=topics caller context=] with default initial field values.
1. Set |topicsCallerContext|'s [=topics caller context/caller domain=] to |requestOrigin|'s [=origin/host=]'s [=host/registrable domain=].
1. Set |topicsCallerContext|'s [=topics caller context/top level context domain=] to |topLevelDocument|'s [=Document/origin=]'s [=origin/host=]'s [=host/registrable domain=].
1. Let |unsafeMoment| be the [=wall clock=]'s [=wall clock/unsafe current time=].
1. Let |moment| be the result of running [=coarsen time=] algorithm given |unsafeMoment| and [=wall clock=] as input.
1. Let |fromUnixEpochTime| be the [=duration from=] the [=Unix epoch=] to |moment|.
1. Set |topicsCallerContext|'s [=topics caller context/timestamp=] to |fromUnixEpochTime|.
1. Let |topics| be an empty [=list=].
1. Let |numVersionsInEpochs| be 0.
1. If the user preference setting and other user agent-defined mechanisms like <a href="https://github.com/privacysandbox/attestation">enrollment</a> allow access to topics from |topLevelDocument| given |requestOrigin|:
1. Set |topics| to the result of running the [=calculate the topics for caller=] algorithm, with |topicsCallerContext| as input.
1. Set |numVersionsInEpochs| to the result of running the [=get the number of distinct versions in epochs=] algorithm, with |topicsCallerContext| as input.
1. Let |versionsToTopics| be an [=ordered map=].
1. For each |topic| of |topics|:
1. Let |version| be |topic|["{{BrowsingTopic/version}}"].
1. Let |topicInteger| be |topic|["{{BrowsingTopic/topic}}"].
1. If |versionsToTopics|[|version|] does not exist, then set it to an empty list.
1. Append |topicInteger| to |versionsToTopics|[|version|].
1. Let |topicsStructuredFieldsList| be an empty <a href="https://www.rfc-editor.org/rfc/rfc8941.html#name-lists">Structured Fields List</a>.
1. For each |version| → |topicIntegers| of |versionsToTopics|:
1. Let |innerList| be an empty <a href="https://www.rfc-editor.org/rfc/rfc8941.html#name-inner-lists">Structured Fields Inner List</a>.
1. Append all items from |topicIntegers| to |innerList|.
1. Let |topicParameters| be an empty [=Structured Fields Parameters=].
1. Set |topicParameters|["<code>v</code>"] to a [=Structured Fields Token=] with value |version|.
1. Associate |topicParameters| with |innerList|.
1. Append |innerList| to |topicsStructuredFieldsList|.
1. If |numVersionsInEpochs| is 0, then set |numVersionsInEpochs| to 1.
1. Let |maxNumberOfEpochs| be 3 (i.e. topics are selected from the last 3 epochs).
1. Let |topicMaxLength| be number of base-10 digits in the maximum [=browsing topics types/topic id=] (e.g. for <a href="https://github.com/patcg-individual-drafts/topics/blob/main/taxonomy_v2.md">Chrome's current taxonomy</a>, |topicMaxLength| is 3, as the [=browsing topics types/topic id=] has maximum 3 digits).
1. Let |versionMaxLength| be the length of the current [=browsing topics types/maximum version string length=].
1. Let |listItemsSeparatorLength| be 2 (i.e. structured fields use two characters (", ") to separate list items).
1. Let |perVersionedTopicsInnerListOverhead| be 5 (i.e. for "();v=")
1. Let |maxPaddingLength| be |maxNumberOfEpochs| * |topicMaxLength| + |maxNumberOfEpochs| - |numVersionsInEpochs| + |numVersionsInEpochs| * |perVersionedTopicsInnerListOverhead| + |numVersionsInEpochs| * |versionMaxLength| + (numVersionsInEpochs - 1) * |listItemsSeparatorLength|.
1. Let |paddingLength| be |maxPaddingLength|.
1. If |topicsStructuredFieldsList| is not empty:
1. Let |serializedTopicsList| be the result of executing the <a href="https://httpwg.org/specs/rfc8941.html#text-serialize">serializing structured fields</a> algorithm on |topicsStructuredFieldsList|.
1. Decrement |paddingLength| by |serializedTopicsList|'s length.
1. Else:
1. Increment |paddingLength| by |listItemsSeparatorLength| (i.e. to account for the separator characters that would be added when |topics| are not empty).
1. If |paddingLength| < 0, then set |paddingLength| to 0.
Note: the padding should generally be ≥ 0. It may be negative in certain circumstances: when historically stored topic versions are greater (and use more digits) than the current [=browsing topics types/maximum version string length=]; or when there is a race between getting topics and getting the number of distinct topic versions. Clamp to 0 to prevent breakage in these rare circumstances.
1. Let |paddedToken| be "P".
1. Append |paddingLength| <code>"0"</code> characters to the end of |paddedToken|.
1. Let |paddedEntryParameters| be an empty [=Structured Fields Parameters=].
1. Set |paddedEntryParameters|["<code>p</code>"] to a [=Structured Fields Token=] with value |paddedToken|.
1. Let |emptyInnerList| be an empty <a href="https://www.rfc-editor.org/rfc/rfc8941.html#name-inner-lists">Structured Fields Inner List</a>.
1. Associate |paddedEntryParameters| with |emptyInnerList|.
1. Append |emptyInnerList| to |topicsStructuredFieldsList|.
1. [=Set a structured field value=] given ([:Sec-Browsing-Topics:], |topicsStructuredFieldsList|) in |request|'s [=request/header list=].
<div class="note">
This algorithm transforms the topics list into structured fields format, which contains paddings to make the total length consistent for different topics callers.
<div class="example">
Empty returned topics, and underlying epochs have same versions:
<code>();p=P0000000000000000000000000000000</code>
</div>
<div class="example">
One returned topic, and underlying epochs have same versions:
<code>(1);v=chrome.1:1:2, ();p=P00000000000</code>
</div>
<div class="example">
Two returned topics, and underlying epochs have same versions:
<code>(1 2);v=chrome.1:1:2, ();p=P000000000</code>
</div>
<div class="example">
Two returned topics, and underlying epochs have two different versions:
<code>(1);v=chrome.1:1:2, (1);v=chrome.1:1:4, ();p=P0000000000</code>
</div>
<div class="example">
Three returned topics, and underlying epochs have three different versions:
<code>(100);v=chrome.1:1:20, (200);v=chrome.1:1:40, (300);v=chrome.1:1:60, ();p=P</code>
</div>
Why adding paddings: servers typically have a GET request size limit e.g. 8KB, and will return an error when the limit is reached. An attacker can rely on this to learn the number of topics for a different domain, and/or a small amount of information about the topics themselves (e.g whether the [=browsing topics types/topic ids=] are < 10, < 100, etc.)
The various lengths being returned (that depends on the number of distinct versions) could leak which epochs the user had disabled topics or didn't use the browser, if it coincided with the version change. But this leak is minor. The most common cases (i.e. returning same version topics, or no topics) will have the same length.
</div>
</div>
<h3 id="the-observe-browsing-topics-http-response-header-header">The \`<code>Observe-Browsing-Topics</code>\` HTTP response header</h3>
The \`<dfn export http-header><code>Observe-Browsing-Topics</code></dfn>\` HTTP response header can be used to record a caller's topics observation.
<div algorithm>
To <dfn>handle topics response</dfn>, given a [=response=] |response| and a [=request=] request:
1. If |request|'s [=request/header list=] does not [=list/contain=] [:Sec-Browsing-Topics:] (implying the |request|'s [=request/current URL=] is not eligible for topics), then return.
1. Let |topLevelDocument| be |request|'s [=request/window=]'s [=environment settings object/global object=]'s [=Window/navigable=]'s [=navigable/top-level traversable=]'s [=navigable/active document=].
1. Let |callerOrigin| be |request|'s [=request/current URL=]'s [=url/origin=].
1. If the user preference setting or other user agent-defined mechanisms like <a href="https://github.com/privacysandbox/attestation">enrollment</a> disallows access to topics from |topLevelDocument| given |callerOrigin|, then return.
1. Let |callerDomain| be |callerOrigin|'s [=origin/host=]'s [=host/registrable domain=].
1. Let |list| be |response|'s [=response/header list=].
1. Let |observe| be the result of running [=get a structured field value=] algorithm given [:Observe-Browsing-Topics:], "item", and |list| as input.
1. If |observe| is true:
1. Run the [=collect page topics calculation input data=] algorithm with |topLevelDocument| as input.
1. Run the [=collect topics caller domain=] algorithm with |topLevelDocument| and |callerDomain| as input.
</div>
<h3 id="modification-to-http-fetch-steps-header">Modification to HTTP fetch steps</h3>
The following step will be added to the [=HTTP fetch=] steps, before checking the redirect status (i.e. "If |actualResponse|'s status is a redirect status, ..."):
1. [=Handle topics response=], given [=response=] |actualResponse| and [=request=] |request| as input.
<span class=XXX>TODO: make the modification directly to the fetch spec.</span>
</section>
<section>
<h2 id="permissions-policy-integration-header">Permissions policy integration</h2>
<p>This specification defines a [=policy-controlled feature=] identified by the string
"<code><dfn id=browsing-topics-policy-controlled-feature>browsing-topics</dfn></code>". Its <a>default allowlist</a> is <code>*</code>.
<p>For backward compatibility, this specification also defines a [=policy-controlled feature=] identified by the string
"<code><dfn id=interest-cohort-policy-controlled-feature>interest-cohort</dfn></code>". Its <a>default allowlist</a> is <code>*</code>.
</section>
<section>
<h2 id="privacy-considerations-header">Privacy considerations</h2>
The Topics API attempts to provide just enough relevant interest information for advertisers to be able to personalize their ads for the user while maintaining user privacy. Some privacy safeguards include: usage in secure contexts only, topic limitation to a human curated taxonomy, different topics given to different sites in the same epoch to prevent cross-site reidentification, noised topics, a limited number of topics provided per epoch, user opt outs, site opt outs, and a suggestion that user agents provide UX to give users choice in which Topics are returned.
</section>