Work package of the create last programme, which is tracked internally. One
agent, this repository, one pull request with two commits, being the cache
record (commit one, closes #21) and the user prompt block with refresh()
and the one instance warning (commit two). It can start at once. Its browser
tests live in pipeline-dotnet and are the work package
pipeline-dotnet#414,
whose branch pins this pull request's head commit, so the two agents work
side by side and the pull requests merge in the order template first.
Read the template from main of this repository. Every line number below is
JavaScriptResource.mustache at commit 13ba5e7, which is main on
14 September 2026 and is what pipeline-dotnet main and the released package
4.5.104 carry.
Not to be done. The loop fix in #17 is already in, commit 0617eea by
Eugene Dorfman on 3 August 2026, pinned by pipeline-dotnet and inside package
4.5.104, with SessionStorageCache_SnippetArrivingInARefreshIsExecuted in
pipeline-dotnet covering it. Do not touch lines 444 to 463.
The rules this implements
Stated by James Rosewell.
- A 51Did is created only after every other piece of data is complete. No
exception, including the two screen size snippets.
- The client script gathers the visitor's answer itself. The publisher
writes no code. The 51Degrees Preference Management Platform (PMP) is
asked first, then the Transparency and Consent Framework. The first source
that has an answer wins and the rest are ignored, and the server applies
the same order. A Global Privacy Platform string is never read, because
the Model Terms for Marketing do not map it, so the block has two sources
and no __gpp code at all.
- A stored answer may be reused only for the same inputs. A different
usage, consent string, salt, resource key or set of page values is a
different input and must produce a fresh request.
- The browser storage key does not change. Invalidation is by clearing a
stale entry.
- The PMP and the client script stay separate. The script listens to the
PMP. The PMP may read the script's object when it is on the page.
- The common case is the late answer. The visitor answers the PMP's first
card after the script has loaded, and the full sequence then runs with
the usage known from its first request. That path is the main flow, not
a recovery path.
- There is no notion of the page having finished. The 51Did is created
whenever the evidence to create it is present, and if new evidence
arrives later refresh() produces a fresh one that replaces the previous
one, which is undocumented behaviour and not designed for. The iterations
of one page view finish when the sequence reaches the server's maximum,
which is a constant, and after that refresh() does no further
processing and says so in the console.
- A stored answer stays in use until the page's consent platform has
answered. No answer yet means unknown, not different.
- The cache record is stored as the plain string. Session storage on the
publisher's origin is reachable only by the joint controllers, being the
publisher and 51Degrees, and the template README changes to say so.
What the template does today, and the five places this touches
- Line 4 renders the query evidence of the script request as a
parameters
object literal. processRequest mutates that object in place at lines
539 to 542 and pushes it into the body at 546 to 550, then pushes every
entry of window["{{_objName}}Evidence"] at 554 to 561, so a key present
in both is sent twice. The server keeps a repeated form key as a
StringValues, every reader asks for a string, and the key then reads as
absent with no warning, so nothing is created. On the PMP's documented
page (id.usage in the script URL) and on every Prebid page (tcstring
in the script URL) the block as first planned would have done exactly
that.
- Line 8 is the storage key,
var sessionKey = "{{_objName}}". clearCache
(57 to 72) removes that key and every key starting sessionKey + "_",
which includes the _data_ snippet values (182) and the _property_
flags written when a response is cached (626 to 633). It runs only on a
failed request (613, 642, 652) and an entry that will not parse (95).
Nothing runs when the inputs change.
processJsProperties reads the cache synchronously at its top (266),
skips every property in jsPropertiesStarted (288 to 290), which is never
emptied, and dispatches processRequest only when callbackCounter
reaches zero (242 to 251).
loadParsedJSON (483 to 501) replaces json with the last response (484)
and re-enters process() at 491 for every round after the first.
completed is set at 399, 494 and 825, failed at 664. The public
surface is sessionId (9), promise (766), onChange (771) and
complete (784), and update (691 to 710) runs once, from the promise
path only (823), so on the non promise path the object never re-publishes
a later response, which is an existing defect.
- Line 833 is
var {{_objName}} = new fiftyoneDegreesManager();, so a
second load silently replaces the first instance.
- The shared code already uses
let (26), for ... of (169) and
replaceAll (325), so the template is not ES5 and nothing below needs to
be written as if it were. crypto.subtle is still out, being asynchronous
and secure context only.
Commit one. The cache record (closes #21)
One body builder. Replace the two push loops with one function that
builds a single map, each key once, later entries replacing earlier: the
rendered parameters from a fresh copy (render line 4 as a factory function
returning a new object, so nothing is ever mutated), then the stored 51D_
values, then the entries of window["{{_objName}}Evidence"] read at that
moment as line 554 does today, then the block's answer from commit two. The
wire body is that map plus session-id and sequence appended last. The
record value, inputs(), is the same map without those two, keys sorted,
each pair encoded the way the body encodes it, joined with &. It is
stored as that plain string, not hashed, which is James Rosewell's decision:
session storage on the publisher's origin is reachable only by the joint
controllers, being the publisher and 51Degrees. The template README at lines
80 to 82 says id.email is "never in the URL, a cookie or web storage", and
changes in the same pull request to say that the inputs of the last request,
id.email included where the page supplied it, are kept in session storage
on the publisher's origin so that a stored answer is never reused for
different inputs. Do not add a hash, however small, because it would be
read as a privacy measure and it is not one.
Where it is compared and stored.
- Once per script run, in the constructor before the first
process(),
after the block has taken its synchronous answer. Where a record exists
under sessionKey + "_inputs" and differs from the current inputs(),
call clearCache(), which removes the payload, the flags and the values,
so the snippets run again on this page view. That is the existing function
doing its existing job. Where it matches, note the value as lastSent so
a later refresh() with unchanged inputs is a no op.
- No answer yet means unknown, not different. Where the block has no
answer at construction but a consent platform is present on the page
(window.__tcfapi is a function, or the PMP's loader or object is
present), the answer key in the stored record stands in for the missing
one in that comparison, so the entry is served until the platform
delivers, and a delivered answer that differs then refreshes. That is
James Rosewell's decision, taken because the literal reading would make a
page whose platform loads after our script pay two full rounds on every
page view for the life of the tab. A page with no platform at all keeps
the literal reading.
- Not inside
process(). process() is re-entered per round at line 491,
and a comparison there would clear the round's own values mid exchange.
- At dispatch, in
processRequest, compute inputs() for the map being
sent and keep it as lastSent.
- When a response is cached at lines 626 to 633, write
lastSent under
sessionKey + "_inputs". The value stored with a response is the record
of the request that produced it, computed at dispatch, never one computed
when the response arrives.
The key name matters. clearCache removes only the exact key and keys
starting sessionKey + "_", and pipeline-dotnet's cache tests assert that
after a failed refresh nothing named fod or starting fod_ remains, that
no key ends _parameters, and that the key set is the same on both page
views and embeds no session id. fod_inputs satisfies all three.
What is deliberately not in the record. The session id and the sequence,
which are added at dispatch and not rendered. The callback URL, which for one
integration is the script's own host and a configured endpoint and never
changes within a browser session, and differs only between two integrations
sharing an object name, which is #19 below. The HTTP headers the script
cannot see, which are the browser's own and are covered by Vary.
Two consequences to write into the pull request. A publisher who puts a
cache buster in the script URL invalidates on every page view, because it
lands in the rendered parameters, and that is accepted because such a
parameter already defeats the half hour client cache on the same response.
And with cookies enabled the snippet values come from cookies and survive
the clear, whilst the flags do not, so the snippets run again in both modes
and with cookies on the re-run overwrites the cookies.
#19 closes against a documentation line. Two integrations on one origin
sharing the default object name now clear each other's entry on every page
view rather than silently reading each other's data, so the template README
states that integrations on one origin must use distinct object names. Write
that line and close #19 against it.
Commit two. The user prompt block, refresh() and the warning
The section. {{#_userPrompt}} ... {{/_userPrompt}}, a plain boolean
section, nested inside the existing {{#_updateEnabled}} section because
without updates no request is ever made and the block has nothing to do. No
inverted counterpart and no iteration. Rust ships its own mustache renderer,
in which an inverted section with an unknown name is skipped rather than
rendered, which is the opposite of every other engine, and it has no list
support. A plain section is omitted identically in all six renderers when
the port does not set the value, which was proved by running each renderer.
Java, Node and PHP never set _supportsFetch either and ship working
script, which is the same mechanism.
Every browser global is guarded. typeof x !== 'undefined' before
reading window.__gpp, window.__tcfapi, localStorage and
window.__51d_pmp, and every call into a platform, every storage read and
the JSON.parse inside a try whose catch means "no answer from that
source". The cloud's two builder unit tests evaluate the rendered script in
NiL.JS, which has no window and no localStorage, pipeline-java's tests
inject the script into Chrome, and Rust's test renders with no browser at
all, so an unguarded read fails three suites. Reading web storage also throws
when the visitor blocks storage, and a consent platform's stub can throw on
any command, and nothing in the block may stop process().
One reader, run on every wake up. readAnswer() runs the priority chain
and returns one of "id.usage=<value>", "tcstring=<string>" or nothing.
- The PMP. Where
window.__51d_pmp.preference is a function, its answer,
because once the bundle has loaded it holds in memory whatever init()
found, in local storage or in the shared store, and that is how a
returning visitor whose answer lives in the shared store has a
synchronous answer on a page that loaded the PMP first. Otherwise the
value last delivered by the PMP's window event (below). Otherwise the
local storage key __51d_pmp_pref, parsed as JSON, taking p, which
covers a script that constructs before the bundle has loaded on a site
where the visitor answered. Accept only non-marketing, standard and
personalized. The alternative answer is non-marketing and is sent as a
usage, because it is an answer under the Model Terms. There is no
refusal in the PMP. Nothing new is written to storage by either
side, on James Rosewell's instruction.
- The Transparency and Consent Framework. The last
tcString delivered by
its listener with success true and eventStatus of tcloaded or
useractioncomplete. A delivery of (null, false) clears it and is never read as an
answer, because on the PMP it is only the TCF view of the alternative
answer, a usage granting no purposes, which the block reads through the
PMP source, and on any other platform it is an error. Send it as tcstring.
There is no Global Privacy Platform step. The server does not read a GPP
string, because the Model Terms do not map it, and an issue in the cloud
repository holds the mapping work. Do not register on window.__gpp and do
not send gpp or gppstring.
Never map a string to a usage on the client. IabTcfElement does that on
the server, and two copies would drift.
Listeners, registered once at construction and never removed. Two,
each wrapped in try, each storing what it was given and then calling
wake().
window.addEventListener('51d-pmp-preference', ...), taking
event.detail.preference. This one is registered whether or not the PMP
has loaded, because the PMP's bundle is loaded asynchronously and may
arrive after this script in either tag order, and it is the only route by
which a PMP answer, the alternative answer and a correction from the
shared store
reach the script as a stated usage.
window.__tcfapi('addEventListener', 2, callback) where __tcfapi is a
function. The specification requires the callback to be invoked at once
with the current data when the platform is loaded, and getTCData is
deprecated in version 2.2 and not required, so neither ping nor
getTCData is called. A page's stub queues the registration until the
platform loads and calls back then, which is the normal route on a page
with a third party platform.
The PMP and a CMP never share a page. That is a documented rule, so
on any page at most one of the two owns window.__tcfapi, being the PMP's
own surface on a PMP page and the CMP on a CMP page, and the block's two
sources never compete with a third. On a CMP page the load order is the
publisher's: the CMP's inline __tcfapi stub precedes this script tag, as
the TCF specification already requires, and a CMP that loads later is fine
because the stub queues the registration.
Two console warnings, inside the section, once per page view, with no
value ever printed. First, where the section was rendered and at
construction neither window.__51d_pmp nor window.__tcfapi exists:
"51Degrees: no preference platform was found on this page. A platform's
stub must precede this script. No 51Did will be created until a platform
answers." A stub missing at construction has no recovery on that page view
and this warning is the only signal, and the documentation says so plainly.
Second, where window.__tcfapi exists but a call into it throws, or
addEventListener has not called back at all within ten seconds of
registration: a warning naming the call, and that call is treated as no
answer. The ten second timer only logs, changes nothing about processing,
and is not a wait.
wake() is var a = readAnswer(); if (a !== answer) { answer = a; refresh(); }.
Because the reader runs the priority chain every time, a later value from
the source that answered replaces the answer, a delivery from a lower ranked
source changes nothing, and no "answered" flag exists. That is what makes a
visitor who chooses standard and then the alternative in the same page
view, or whose
answer arrives from the shared store after the script constructed, produce
the fresh request rule 3 requires.
A listener can fire synchronously inside its own registration, before
process() has run and before line 833 has assigned the object. That is
safe because completed is false from line 23 until the first round ends,
so refresh() sees a round in progress and only marks it pending, and the
first request then carries the answer anyway. At the end of the constructor
the order is register the two listeners, answer = readAnswer(), then
process().
The answer goes into the body map, not onto the evidence object. In the
body builder, when answer is set, delete id.usage, gpp, gppstring
and tcstring from the map and set the one key the answer names (the two
GPP keys are deleted because the server no longer reads them and there is
no point carrying them). The body therefore carries one value for one key,
the block's answer replaces a value the script URL carried, and a page with
no platform at all sends whatever its URL and evidence object carried, as
today. Nothing is written to window["{{_objName}}Evidence"].
refresh(). Public, beside onChange and complete, returns nothing.
function refresh() {
if (sequence >= maxIterations) { // the page view's iterations are finished
console.log("51Degrees: the maximum of " + maxIterations + " iterations for this page view has been reached, refresh() does nothing.");
return;
}
if (!completed && !failed) { pending = true; return; } // a round is in progress
if (inputs() === lastSent) { return; } // nothing changed
completed = false; failed = false;
jsPropertiesStarted = []; jsPropertiesPending = [];
processRequest(); // stored values, no clearing, no re-run
}
maxIterations is a literal 10 in the template with a comment naming
MAX_JAVASCRIPT_ITERATIONS in pipeline-dotnet's JSON builder constants,
pinned by a pipeline-dotnet test that reads the template text and compares
the two, the way ClientScriptResultExpressionTests in the cloud pins the
result expression. The sequence keeps counting across refreshes and is
never reset, because it is the page view's iteration count, and the server
stops listing snippets at the same number. That is James Rosewell's
decision, together with the console message.
Four things in that.
-
A round is the whole exchange, from process() through every snippet
callback to the response that lists nothing more, and completed || failed
already expresses it. callbackCounter is not built for two rounds at
once, and "in flight" is the wrong unit, because the snippets are running
asynchronously before the first request goes.
-
An answer that arrives while a round is in progress is carried by that
round's own next request, because the body is built at dispatch from the
current answer. Only an answer that arrives after the round's last
request needs a request of its own, and that is what the pending flag is
for. At every round end (after completed = true at 494 and 399, and in
the failure exits) run if (pending) { pending = false; refresh(); }.
This is the common case, and it was measured with a stubbed endpoint. As
first planned the refresh would have sent the answer at once with no
snippet results, which creates the identifier against the unresolved
device, the defect this whole programme exists to remove.
-
An idle refresh does not clear the cache and does not run the snippets
again. Screen size, pixel ratio, client hints and the Apple profile read
the device, not the consent state, so a second run gathers the same
values and costs a full detection round, and a delayed snippet's value
(the location) would be deleted and never re-gathered because process()
passes ignoreDelayFlag false. The request carries the values in storage
plus the answer, and the response replaces the payload and the record.
James Rosewell's "no exception" ruling is that the request which creates
the identifier must carry every snippet's result, and it does.
-
Below the cap, a refresh sends the stored values because nothing cleared
them, so the identifier is created against the resolved device. At the
cap the page view is finished and nothing more is sent.
update moves into loadParsedJSON, so fod.fodid and every other
section reflect the latest response on both the promise and the non promise
paths, and a page that registered complete(cb) after calling refresh()
is called when the refresh round ends, because refresh() reset completed.
Keep this.promise for the first round rather than reassigning it per
refresh, so page code holding it is not surprised.
Two identifiers from one page view are never byte identical. The
signature carries a fresh random nonce per call, so a page that refreshes
twice holds two strings that verify the same but differ, and fod.fodid
holds whichever response arrived last. Say so in the refresh() comment,
and that page code reads the identifier after the last refresh it made has
completed.
The warning. Immediately before line 833, with nothing after the
constructor line:
if (typeof window["{{_objName}}"] !== 'undefined') {
console.warn("51Degrees: {{_objName}} already exists on this page. Loading the script twice replaces it. Load it once and call {{_objName}}.refresh() to update.");
}
Test the value, not the property. var {{_objName}} at top level creates
the global property with value undefined before any statement runs, so
'fod' in window warns on every load. The text names the object and nothing
else, because printing the existing object prints its payload including
identifiers.
The warning must not fire when the Preference Management Platform adds
the script. From 15 September 2026 that platform adds this script to a
page that carries no such object, which is tracked internally.
It only ever adds it when the object is undefined, so the value test above
is already right. Say so in the comment beside the warning, so the next
reader does not change the test to a property test and break it.
Formatting constraints, because five ports' tests will run this text.
No document.cookie anywhere in the new code, since Java, Node, Python, PHP
and .NET count its occurrences. No {{ or }} in the script text outside a
mustache tag, so put a newline or a space between adjacent closing braces,
since Rust asserts their absence and every engine treats {{ as a tag. Every
new tag a closed section. Nothing after line 833 and no trailing whitespace,
since Rust asserts the script ends with the constructor line.
A simplification on the way through. Fold the three per path error exits
(613, 642, 652) into one failRequest helper, since the block's round end
hook has to go in each of them.
What each port gets in phase one
The record, the clearing, refresh() and the warning sit outside the
conditional section, so every port's generated script changes, by about 365
bytes gzipped. The block adds about
925 bytes gzipped on entitled .NET pages only. Java, Node and Python take
the template through their nightly submodule update, PHP copies main
nightly into its vendored file with an automated pull request, and Rust's
drift check compares byte for byte against main on every pull request, so
Rust turns red the moment this merges and the Rust work package pins that
check to a commit first. Python's chevron renderer was proved to omit an
unset section, so no manual check is needed.
How it is verified
Every browser test for the client script lives in pipeline-dotnet's
JavaScriptBuilderElementTests, and the ones for this work are written
against the SessionStorageCacheTests harness there, which serves a real
page from a real pipeline and captures every POST body. The consumer
workflow in this repository, consumer-tests.yml, checks out pipeline-dotnet
at inputs.pipeline-dotnet-ref or main and copies this template in, but
on a pull request event the input is empty, so the run builds main, which
has no plumbing for the section, and cannot exercise any block test. The
proof is therefore two runs: this repository's own consumer run, which
covers commit one and the regression suite against main, and a dispatch of
that workflow with pipeline-dotnet-ref set to the pipeline-dotnet work
package's branch, which carries the plumbing, the block tests and a submodule
pointer at this pull request's head. Open the "Swap in the template under
review" step and check it reports a changed file, or, where the branch
already pins this head, that the checkout step names this commit.
The tests themselves are listed in the pipeline-dotnet work package. The
ones that exist and must still pass untouched are
SessionStorageCache_SecondPageIsServedFromCache, which is the proof that
unchanged inputs are still served from the cache, and the three that pin the
key shape and the clear.
Not part of this
- The Prebid module. Its workaround of removing the
fod entry on a
consent change becomes redundant, and its practice of loading the script
again on a consent change trips the warning. Both point at it calling
refresh() instead, which belongs in its own repository.
- The mapping of a consent string to a usage, which stays on the server.
Every line number above
was read from main at 13ba5e7 on 14 September 2026, and the behaviour
claims about the server, the renderers and the harness came from nine
parallel reviews on that day, each asked to cite a path and a line for every
claim, recorded on the parent issue.
Work package of the create last programme, which is tracked internally. One
agent, this repository, one pull request with two commits, being the cache
record (commit one, closes #21) and the user prompt block with
refresh()and the one instance warning (commit two). It can start at once. Its browser
tests live in pipeline-dotnet and are the work package
pipeline-dotnet#414,
whose branch pins this pull request's head commit, so the two agents work
side by side and the pull requests merge in the order template first.
Read the template from
mainof this repository. Every line number below isJavaScriptResource.mustacheat commit 13ba5e7, which ismainon14 September 2026 and is what pipeline-dotnet
mainand the released package4.5.104 carry.
Not to be done. The loop fix in #17 is already in, commit 0617eea by
Eugene Dorfman on 3 August 2026, pinned by pipeline-dotnet and inside package
4.5.104, with
SessionStorageCache_SnippetArrivingInARefreshIsExecutedinpipeline-dotnet covering it. Do not touch lines 444 to 463.
The rules this implements
Stated by James Rosewell.
exception, including the two screen size snippets.
writes no code. The 51Degrees Preference Management Platform (PMP) is
asked first, then the Transparency and Consent Framework. The first source
that has an answer wins and the rest are ignored, and the server applies
the same order. A Global Privacy Platform string is never read, because
the Model Terms for Marketing do not map it, so the block has two sources
and no
__gppcode at all.usage, consent string, salt, resource key or set of page values is a
different input and must produce a fresh request.
stale entry.
PMP. The PMP may read the script's object when it is on the page.
card after the script has loaded, and the full sequence then runs with
the usage known from its first request. That path is the main flow, not
a recovery path.
whenever the evidence to create it is present, and if new evidence
arrives later
refresh()produces a fresh one that replaces the previousone, which is undocumented behaviour and not designed for. The iterations
of one page view finish when the sequence reaches the server's maximum,
which is a constant, and after that
refresh()does no furtherprocessing and says so in the console.
answered. No answer yet means unknown, not different.
publisher's origin is reachable only by the joint controllers, being the
publisher and 51Degrees, and the template README changes to say so.
What the template does today, and the five places this touches
parametersobject literal.
processRequestmutates that object in place at lines539 to 542 and pushes it into the body at 546 to 550, then pushes every
entry of
window["{{_objName}}Evidence"]at 554 to 561, so a key presentin both is sent twice. The server keeps a repeated form key as a
StringValues, every reader asks for a string, and the key then reads asabsent with no warning, so nothing is created. On the PMP's documented
page (
id.usagein the script URL) and on every Prebid page (tcstringin the script URL) the block as first planned would have done exactly
that.
var sessionKey = "{{_objName}}".clearCache(57 to 72) removes that key and every key starting
sessionKey + "_",which includes the
_data_snippet values (182) and the_property_flags written when a response is cached (626 to 633). It runs only on a
failed request (613, 642, 652) and an entry that will not parse (95).
Nothing runs when the inputs change.
processJsPropertiesreads the cache synchronously at its top (266),skips every property in
jsPropertiesStarted(288 to 290), which is neveremptied, and dispatches
processRequestonly whencallbackCounterreaches zero (242 to 251).
loadParsedJSON(483 to 501) replacesjsonwith the last response (484)and re-enters
process()at 491 for every round after the first.completedis set at 399, 494 and 825,failedat 664. The publicsurface is
sessionId(9),promise(766),onChange(771) andcomplete(784), andupdate(691 to 710) runs once, from the promisepath only (823), so on the non promise path the object never re-publishes
a later response, which is an existing defect.
var {{_objName}} = new fiftyoneDegreesManager();, so asecond load silently replaces the first instance.
let(26),for ... of(169) andreplaceAll(325), so the template is not ES5 and nothing below needs tobe written as if it were.
crypto.subtleis still out, being asynchronousand secure context only.
Commit one. The cache record (closes #21)
One body builder. Replace the two push loops with one function that
builds a single map, each key once, later entries replacing earlier: the
rendered parameters from a fresh copy (render line 4 as a factory function
returning a new object, so nothing is ever mutated), then the stored
51D_values, then the entries of
window["{{_objName}}Evidence"]read at thatmoment as line 554 does today, then the block's answer from commit two. The
wire body is that map plus
session-idandsequenceappended last. Therecord value,
inputs(), is the same map without those two, keys sorted,each pair encoded the way the body encodes it, joined with
&. It isstored as that plain string, not hashed, which is James Rosewell's decision:
session storage on the publisher's origin is reachable only by the joint
controllers, being the publisher and 51Degrees. The template README at lines
80 to 82 says
id.emailis "never in the URL, a cookie or web storage", andchanges in the same pull request to say that the inputs of the last request,
id.emailincluded where the page supplied it, are kept in session storageon the publisher's origin so that a stored answer is never reused for
different inputs. Do not add a hash, however small, because it would be
read as a privacy measure and it is not one.
Where it is compared and stored.
process(),after the block has taken its synchronous answer. Where a record exists
under
sessionKey + "_inputs"and differs from the currentinputs(),call
clearCache(), which removes the payload, the flags and the values,so the snippets run again on this page view. That is the existing function
doing its existing job. Where it matches, note the value as
lastSentsoa later
refresh()with unchanged inputs is a no op.answer at construction but a consent platform is present on the page
(
window.__tcfapiis a function, or the PMP's loader or object ispresent), the answer key in the stored record stands in for the missing
one in that comparison, so the entry is served until the platform
delivers, and a delivered answer that differs then refreshes. That is
James Rosewell's decision, taken because the literal reading would make a
page whose platform loads after our script pay two full rounds on every
page view for the life of the tab. A page with no platform at all keeps
the literal reading.
process().process()is re-entered per round at line 491,and a comparison there would clear the round's own values mid exchange.
processRequest, computeinputs()for the map beingsent and keep it as
lastSent.lastSentundersessionKey + "_inputs". The value stored with a response is the recordof the request that produced it, computed at dispatch, never one computed
when the response arrives.
The key name matters.
clearCacheremoves only the exact key and keysstarting
sessionKey + "_", and pipeline-dotnet's cache tests assert thatafter a failed refresh nothing named
fodor startingfod_remains, thatno key ends
_parameters, and that the key set is the same on both pageviews and embeds no session id.
fod_inputssatisfies all three.What is deliberately not in the record. The session id and the sequence,
which are added at dispatch and not rendered. The callback URL, which for one
integration is the script's own host and a configured endpoint and never
changes within a browser session, and differs only between two integrations
sharing an object name, which is #19 below. The HTTP headers the script
cannot see, which are the browser's own and are covered by
Vary.Two consequences to write into the pull request. A publisher who puts a
cache buster in the script URL invalidates on every page view, because it
lands in the rendered parameters, and that is accepted because such a
parameter already defeats the half hour client cache on the same response.
And with cookies enabled the snippet values come from cookies and survive
the clear, whilst the flags do not, so the snippets run again in both modes
and with cookies on the re-run overwrites the cookies.
#19 closes against a documentation line. Two integrations on one origin
sharing the default object name now clear each other's entry on every page
view rather than silently reading each other's data, so the template README
states that integrations on one origin must use distinct object names. Write
that line and close #19 against it.
Commit two. The user prompt block,
refresh()and the warningThe section.
{{#_userPrompt}} ... {{/_userPrompt}}, a plain booleansection, nested inside the existing
{{#_updateEnabled}}section becausewithout updates no request is ever made and the block has nothing to do. No
inverted counterpart and no iteration. Rust ships its own mustache renderer,
in which an inverted section with an unknown name is skipped rather than
rendered, which is the opposite of every other engine, and it has no list
support. A plain section is omitted identically in all six renderers when
the port does not set the value, which was proved by running each renderer.
Java, Node and PHP never set
_supportsFetcheither and ship workingscript, which is the same mechanism.
Every browser global is guarded.
typeof x !== 'undefined'beforereading
window.__gpp,window.__tcfapi,localStorageandwindow.__51d_pmp, and every call into a platform, every storage read andthe
JSON.parseinside atrywhose catch means "no answer from thatsource". The cloud's two builder unit tests evaluate the rendered script in
NiL.JS, which has no
windowand nolocalStorage, pipeline-java's testsinject the script into Chrome, and Rust's test renders with no browser at
all, so an unguarded read fails three suites. Reading web storage also throws
when the visitor blocks storage, and a consent platform's stub can throw on
any command, and nothing in the block may stop
process().One reader, run on every wake up.
readAnswer()runs the priority chainand returns one of
"id.usage=<value>","tcstring=<string>"or nothing.window.__51d_pmp.preferenceis a function, its answer,because once the bundle has loaded it holds in memory whatever
init()found, in local storage or in the shared store, and that is how a
returning visitor whose answer lives in the shared store has a
synchronous answer on a page that loaded the PMP first. Otherwise the
value last delivered by the PMP's window event (below). Otherwise the
local storage key
__51d_pmp_pref, parsed as JSON, takingp, whichcovers a script that constructs before the bundle has loaded on a site
where the visitor answered. Accept only
non-marketing,standardandpersonalized. The alternative answer isnon-marketingand is sent as ausage, because it is an answer under the Model Terms. There is no
refusal in the PMP. Nothing new is written to storage by either
side, on James Rosewell's instruction.
tcStringdelivered byits listener with
successtrue andeventStatusoftcloadedoruseractioncomplete. A delivery of(null, false)clears it and is never read as ananswer, because on the PMP it is only the TCF view of the alternative
answer, a usage granting no purposes, which the block reads through the
PMP source, and on any other platform it is an error. Send it as
tcstring.There is no Global Privacy Platform step. The server does not read a GPP
string, because the Model Terms do not map it, and an issue in the cloud
repository holds the mapping work. Do not register on
window.__gppand donot send
gpporgppstring.Never map a string to a usage on the client.
IabTcfElementdoes that onthe server, and two copies would drift.
Listeners, registered once at construction and never removed. Two,
each wrapped in
try, each storing what it was given and then callingwake().window.addEventListener('51d-pmp-preference', ...), takingevent.detail.preference. This one is registered whether or not the PMPhas loaded, because the PMP's bundle is loaded asynchronously and may
arrive after this script in either tag order, and it is the only route by
which a PMP answer, the alternative answer and a correction from the
shared store
reach the script as a stated usage.
window.__tcfapi('addEventListener', 2, callback)where__tcfapiis afunction. The specification requires the callback to be invoked at once
with the current data when the platform is loaded, and
getTCDataisdeprecated in version 2.2 and not required, so neither
pingnorgetTCDatais called. A page's stub queues the registration until theplatform loads and calls back then, which is the normal route on a page
with a third party platform.
The PMP and a CMP never share a page. That is a documented rule, so
on any page at most one of the two owns
window.__tcfapi, being the PMP'sown surface on a PMP page and the CMP on a CMP page, and the block's two
sources never compete with a third. On a CMP page the load order is the
publisher's: the CMP's inline
__tcfapistub precedes this script tag, asthe TCF specification already requires, and a CMP that loads later is fine
because the stub queues the registration.
Two console warnings, inside the section, once per page view, with no
value ever printed. First, where the section was rendered and at
construction neither
window.__51d_pmpnorwindow.__tcfapiexists:"51Degrees: no preference platform was found on this page. A platform's
stub must precede this script. No 51Did will be created until a platform
answers." A stub missing at construction has no recovery on that page view
and this warning is the only signal, and the documentation says so plainly.
Second, where
window.__tcfapiexists but a call into it throws, oraddEventListenerhas not called back at all within ten seconds ofregistration: a warning naming the call, and that call is treated as no
answer. The ten second timer only logs, changes nothing about processing,
and is not a wait.
wake()isvar a = readAnswer(); if (a !== answer) { answer = a; refresh(); }.Because the reader runs the priority chain every time, a later value from
the source that answered replaces the answer, a delivery from a lower ranked
source changes nothing, and no "answered" flag exists. That is what makes a
visitor who chooses standard and then the alternative in the same page
view, or whose
answer arrives from the shared store after the script constructed, produce
the fresh request rule 3 requires.
A listener can fire synchronously inside its own registration, before
process()has run and before line 833 has assigned the object. That issafe because
completedis false from line 23 until the first round ends,so
refresh()sees a round in progress and only marks it pending, and thefirst request then carries the answer anyway. At the end of the constructor
the order is register the two listeners,
answer = readAnswer(), thenprocess().The answer goes into the body map, not onto the evidence object. In the
body builder, when
answeris set, deleteid.usage,gpp,gppstringand
tcstringfrom the map and set the one key the answer names (the twoGPP keys are deleted because the server no longer reads them and there is
no point carrying them). The body therefore carries one value for one key,
the block's answer replaces a value the script URL carried, and a page with
no platform at all sends whatever its URL and evidence object carried, as
today. Nothing is written to
window["{{_objName}}Evidence"].refresh(). Public, besideonChangeandcomplete, returns nothing.maxIterationsis a literal10in the template with a comment namingMAX_JAVASCRIPT_ITERATIONSin pipeline-dotnet's JSON builder constants,pinned by a pipeline-dotnet test that reads the template text and compares
the two, the way
ClientScriptResultExpressionTestsin the cloud pins theresult expression. The sequence keeps counting across refreshes and is
never reset, because it is the page view's iteration count, and the server
stops listing snippets at the same number. That is James Rosewell's
decision, together with the console message.
Four things in that.
A round is the whole exchange, from
process()through every snippetcallback to the response that lists nothing more, and
completed || failedalready expresses it.
callbackCounteris not built for two rounds atonce, and "in flight" is the wrong unit, because the snippets are running
asynchronously before the first request goes.
An answer that arrives while a round is in progress is carried by that
round's own next request, because the body is built at dispatch from the
current
answer. Only an answer that arrives after the round's lastrequest needs a request of its own, and that is what the pending flag is
for. At every round end (after
completed = trueat 494 and 399, and inthe failure exits) run
if (pending) { pending = false; refresh(); }.This is the common case, and it was measured with a stubbed endpoint. As
first planned the refresh would have sent the answer at once with no
snippet results, which creates the identifier against the unresolved
device, the defect this whole programme exists to remove.
An idle refresh does not clear the cache and does not run the snippets
again. Screen size, pixel ratio, client hints and the Apple profile read
the device, not the consent state, so a second run gathers the same
values and costs a full detection round, and a delayed snippet's value
(the location) would be deleted and never re-gathered because
process()passes
ignoreDelayFlagfalse. The request carries the values in storageplus the answer, and the response replaces the payload and the record.
James Rosewell's "no exception" ruling is that the request which creates
the identifier must carry every snippet's result, and it does.
Below the cap, a refresh sends the stored values because nothing cleared
them, so the identifier is created against the resolved device. At the
cap the page view is finished and nothing more is sent.
updatemoves intoloadParsedJSON, sofod.fodidand every othersection reflect the latest response on both the promise and the non promise
paths, and a page that registered
complete(cb)after callingrefresh()is called when the refresh round ends, because
refresh()resetcompleted.Keep
this.promisefor the first round rather than reassigning it perrefresh, so page code holding it is not surprised.
Two identifiers from one page view are never byte identical. The
signature carries a fresh random nonce per call, so a page that refreshes
twice holds two strings that verify the same but differ, and
fod.fodidholds whichever response arrived last. Say so in the
refresh()comment,and that page code reads the identifier after the last refresh it made has
completed.
The warning. Immediately before line 833, with nothing after the
constructor line:
Test the value, not the property.
var {{_objName}}at top level createsthe global property with value
undefinedbefore any statement runs, so'fod' in windowwarns on every load. The text names the object and nothingelse, because printing the existing object prints its payload including
identifiers.
The warning must not fire when the Preference Management Platform adds
the script. From 15 September 2026 that platform adds this script to a
page that carries no such object, which is tracked internally.
It only ever adds it when the object is undefined, so the value test above
is already right. Say so in the comment beside the warning, so the next
reader does not change the test to a property test and break it.
Formatting constraints, because five ports' tests will run this text.
No
document.cookieanywhere in the new code, since Java, Node, Python, PHPand .NET count its occurrences. No
{{or}}in the script text outside amustache tag, so put a newline or a space between adjacent closing braces,
since Rust asserts their absence and every engine treats
{{as a tag. Everynew tag a closed section. Nothing after line 833 and no trailing whitespace,
since Rust asserts the script ends with the constructor line.
A simplification on the way through. Fold the three per path error exits
(613, 642, 652) into one
failRequesthelper, since the block's round endhook has to go in each of them.
What each port gets in phase one
The record, the clearing,
refresh()and the warning sit outside theconditional section, so every port's generated script changes, by about 365
bytes gzipped. The block adds about
925 bytes gzipped on entitled .NET pages only. Java, Node and Python take
the template through their nightly submodule update, PHP copies
mainnightly into its vendored file with an automated pull request, and Rust's
drift check compares byte for byte against
mainon every pull request, soRust turns red the moment this merges and the Rust work package pins that
check to a commit first. Python's chevron renderer was proved to omit an
unset section, so no manual check is needed.
How it is verified
Every browser test for the client script lives in pipeline-dotnet's
JavaScriptBuilderElementTests, and the ones for this work are writtenagainst the
SessionStorageCacheTestsharness there, which serves a realpage from a real pipeline and captures every POST body. The consumer
workflow in this repository,
consumer-tests.yml, checks out pipeline-dotnetat
inputs.pipeline-dotnet-reformainand copies this template in, buton a pull request event the input is empty, so the run builds
main, whichhas no plumbing for the section, and cannot exercise any block test. The
proof is therefore two runs: this repository's own consumer run, which
covers commit one and the regression suite against
main, and a dispatch ofthat workflow with
pipeline-dotnet-refset to the pipeline-dotnet workpackage's branch, which carries the plumbing, the block tests and a submodule
pointer at this pull request's head. Open the "Swap in the template under
review" step and check it reports a changed file, or, where the branch
already pins this head, that the checkout step names this commit.
The tests themselves are listed in the pipeline-dotnet work package. The
ones that exist and must still pass untouched are
SessionStorageCache_SecondPageIsServedFromCache, which is the proof thatunchanged inputs are still served from the cache, and the three that pin the
key shape and the clear.
Not part of this
fodentry on aconsent change becomes redundant, and its practice of loading the script
again on a consent change trips the warning. Both point at it calling
refresh()instead, which belongs in its own repository.Every line number above
was read from
mainat 13ba5e7 on 14 September 2026, and the behaviourclaims about the server, the renderers and the harness came from nine
parallel reviews on that day, each asked to cite a path and a line for every
claim, recorded on the parent issue.