Measured, not asserted

Does obfuscation break attribution windows?

Every failure this series has measured is computed once and read once. However wrong the answer is, it is as wrong on the first run as it will ever be. An attribution report is not read once: it is the input to the next period's budget, which decides the next period's touchpoints, which are the next attribution report. This test renamed the option that names the credit model, against a real obfuscation engine. Period one came out identical to the cent. Period two went from 1,473 conversions to 717, period three from 1,656 to 502, and period four from 1,762 to 394 -- on exactly the same money.

Period one is identical in both runs

The fixture is a four-period flight on a hundred thousand a period. Three channels originate demand at a known cost per acquisition -- display at 40, online video at 55, connected television at 70. Two channels harvest it: retargeting follows people already in the funnel and brand search collects people who already decided, so a dollar spent in either creates nothing incremental. That is not a controversial model; it is why incrementality testing exists.

The advertiser credits the touch that STARTED the journey, because that is the one that created the demand. The library's default credits the touch that CLOSED it.

Correct, the flight delivers 1,148 conversions in period one, then 1,473, then 1,656, then 1,762: 6,039 in total at a blended cost of 66.24. Rename the credit model and it delivers 1,148, then 717, then 502, then 394: 2,761 in total at 144.88.

Look at period one. It is 1,148 in both, at a cost per acquisition of 87.11 in both. The opening split has not been reallocated yet, so there is nothing for a wrong report to have moved. A test that runs the flight and checks the numbers, written the cheapest way anybody would write it, is written against the one cycle in which the correct engine and the broken one are indistinguishable.

Why the gap widens instead of staying put

The plan moves half the distance toward the recommended split each period, which is what a sane media team does rather than lurching. So the divergence is not a step, it is a ramp: 0, then 756, then 1,154, then 1,368 conversions apart.

The mechanism is the feedback. A last-touch report credits brand search with essentially everything, so the budget drains toward brand search, so fewer originating impressions are bought, so fewer people enter the funnel for brand search to collect -- and the report still credits brand search with everything that is left. The cost per acquisition goes 87.11, 139.47, 199.20, 253.81 while the correct run goes 87.11, 67.89, 60.39, 56.75.

This is the part that separates it from every other shape in this series. A wrong tax rate is wrong by a fixed amount. A wrong attribution model is wrong by an amount that is a function of how long you have been believing it, and the earliest measurement -- the one a quarterly review is most likely to have on file -- is the one where the gap is smallest.

Three names, one answer, three different routes

^(creditModel)$, ^(lookbackDays)$ and ^(attributableKinds)$ all produce the identical broken flight: 2,761 conversions at 144.88. They get there three different ways.

The model arm swaps first-touch for last-touch outright. The window arm leaves the model alone and shortens the lookback from twenty-one days to the library's seven, so the originating impression at eighteen days falls out of the window and the first SURVIVING touch is retargeting. The kind arm leaves both alone and drops view-through, so the originating impression is not attributable at all -- display, online video and connected television are bought on impressions, and a click-only rule can never credit any of them with anything.

Three names on three different lines, meaning three different things, and one answer. That is not a coincidence: they all remove the same touch from the front of the journey, and a report that cannot see the top of the funnel credits the bottom of it.

The negative control is the advertiser next door

The fixture runs a second advertiser through the same engine, the same options, the same four periods and the same market. DIRECTLINE buys single-touch: one click, two days out, no harvesting channels.

With one eligible touch, the first touch IS the last touch, so the model cannot disagree with itself. Seven days contains a two-day-old touch, so the window cannot drop it. The touch is a click, so a click-only filter keeps it. All three names are inert and all four periods are identical to the cent: 6,039 conversions at 66.24, in every arm measured.

Whether an attribution error is visible at all is a property of the marketing mix, not of the engine -- and the mixes where it is visible are the ones spending the most money on the top of the funnel. A suite whose fixture is a single-touch direct-response advertiser cannot see this class of failure at all.

Everything the attribution check asserts is true in both

The advertiser's own rule in the fixture is the rule anybody would write: every conversion in the period must be accounted for, either credited to a channel or counted as unattributed. It prints attrib-check(1148 of 1148 journey(s) considered, 1148 credited across 5 channel(s), 0 unattributed) in the correct run.

It prints the same class of sentence in every broken arm, because every credit model divides exactly one conversion across the eligible touches. The credited total always equals the conversion count. Nothing is dropped, nothing is double-counted, and foots=true throughout.

Switching the rule off changes nothing that matters. ^(requireAttribCheck)$ and ^(attribRule)$ each move only the line that reports the policy -- 6,039 conversions at 66.24 in both -- and ^(requireAttribCheck|creditModel)$ is byte-identical to renaming the model alone. The rule was contributing nothing to remove, because it is defined over conservation and reassignment conserves everything.

The loop's own shape has its own arms. ^(periods)$ collapses the flight to one period, which is the only arm in the file that hides the failure by never running it. ^(minChannelSharePct)$ removes the floor that stops a channel being switched off outright, and ^(reallocationRate)$ removes the blend, so the plan lurches straight to the recommendation instead of ramping toward it.

What to change, whether or not you obfuscate

None of this needs an obfuscator. A partial response from a configuration service, a dropped key over a serialisation hop, a library upgrade that renames an option and a hand-edited JSON file all produce the same missing name, and the loop turns any of them into a compounding error.

Assert on the SHAPE of the answer, not only its conservation. A report in which two harvesting channels hold ninety per cent of the credit is a report worth refusing, whatever it foots to. Conservation checks pass in every arm here because reassignment conserves by construction.

Test the loop over several periods with a multi-touch fixture. One period proves nothing at all in this area -- it is provably identical in the correct and the broken run -- and a single-touch fixture is structurally blind to the whole class.

If you do obfuscate, keep member renaming narrow and exclude the options object and the touchpoint records. Renaming identifiers, renaming globals and control-flow transformation are unaffected: the base column measured all five areas on five presets and every one was byte-identical. The same care applies to measurement code shipped inside paywall and metering JavaScript, where the counting rules and the entitlement rules sit on the same object.

How this was measured

A grounding harness drives the protection engine directly. Every sample is deterministic and the unprotected original is run twice and required to reproduce itself before anything is compared to it. The simulated attribution library is copied in unprotected, and the conversion feed arrives as text that is parsed, so only this bundle's READ of those field names can move.

Before any renaming, every option key and record field was deleted one at a time to establish that it moves the output at all: 100 arms across the five areas, five of them structurally blind on the first run, all five fixture defects, all five fixed before anything was measured.

The base column ran all five areas on five presets and all twenty-five were byte-identical to the unprotected run, so there is no new engine defect in this pass. For the wider question of what protection is worth on code that runs inside somebody else's ad slot, see obfuscating ad tech JavaScript.

Frequently asked questions

Did obfuscation on its own break attribution?

No. Five presets -- the ES5 default, the modern target, the emit gate's option set on both targets, and the string transforms -- all produced output byte-identical to the unprotected run across all five areas measured this pass.

What actually broke it, then?

Member renaming with a pattern that reached the names attribution is made of: the credit model, the lookback window, the list of attributable touch kinds, and the touchpoint record fields the window is evaluated over.

Why was period one identical in both runs?

Because attribution had not fed back yet. The opening split is whatever the plan launched with, so a correct report and a broken one produce the same budget, the same touchpoints and the same 1,148 conversions at a cost of 87.11. The gap opens in period two.

Why does the error grow instead of staying the same size?

The report is the next period's input. Each period moves the budget further toward channels that harvest demand rather than create it, so fewer people enter the funnel, so the report credits the harvesters with a larger share of a smaller number. Cost per acquisition went 87.11, 139.47, 199.20, 253.81.

Would a simpler advertiser have shown this?

No, and that is the sharp part. The fixture's second advertiser buys single-touch, click-only, two days out. With one eligible touch the model, the window and the kind filter are all inert, and all four periods are identical to the cent in every arm.

Why did the attribution check not catch it?

It is defined over conservation -- every conversion credited exactly once or counted as unattributed -- and reassigning credit conserves everything. It printed the same class of sentence in every arm, and switching it off changed nothing.

What is the safest way to use member renaming here?

Keep the pattern narrow and explicit and exclude any options object carrying attribution models, lookback windows or touch-kind lists, along with the journey records they are evaluated against. Renaming identifiers and control flow is unaffected by all of this.

Related reading