Skip to content

Client script: the cache record, the user prompt block, refresh() and the one instance warning #22

Description

@jwrosewell

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.

  1. A 51Did is created only after every other piece of data is complete. No
    exception, including the two screen size snippets.
  2. 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.
  3. 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.
  4. The browser storage key does not change. Invalidation is by clearing a
    stale entry.
  5. 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.
  6. 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.
  7. 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.
  8. A stored answer stays in use until the page's consent platform has
    answered. No answer yet means unknown, not different.
  9. 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.

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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    type: cleanupTidying that changes no behaviour

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions