Skip to content

fix: matching consumed the click, breaking reinstalls and repeat matches - #21

Merged
Newtdev merged 1 commit into
mainfrom
fix/17-attribution-consumes-click
Sep 15, 2026
Merged

Newtdev merged 1 commit into
mainfrom
fix/17-attribution-consumes-click

Conversation

@Newtdev

@Newtdev Newtdev commented Sep 15, 2026

Copy link
Copy Markdown
Owner

Closes #17.

/match locked the winning click to the device while the candidate query only considered unmatched clicks — WHERE matched = 0 in PHP, eq(referralClicks.matched, false) in Node. A matched click therefore became invisible to everyone, including the device that had just matched it. lockToDevice()'s own docblock stated the original intent: "so it can never be matched twice." That intent was the bug.

The principle behind the fix

The SDK's job is to report what was actually clicked. Preventing a referral code from being redeemed twice belongs in whatever records signups and rewards — that layer knows what redemption means in a given product; the matcher does not. Making the click disappear was the SDK enforcing a policy that was never its own.

So matched now means "last matched by", not "used up".

Resolution

Removing the filter alone would have been wrong in one direction: a device whose fingerprint later stops clearing the threshold (its IP moved, say) would lose an attribution it had already been given.

existing binding new candidate above threshold result
no yes best candidate — unchanged
yes no keep the existing binding
yes yes whichever click is newer

Two constraints that are easy to get wrong:

  • Compare by click time, not confidence. A stale click that happens to score higher is still the wrong answer. Attribution is last-click-wins.
  • A newer click below the threshold must not take over, or an unrelated recent click could steal an attribution merely by being recent.

bindMatch() rather than a looser lockToDevice()

lockToDevice()'s matched = 0 predicate is a security guard on the /claim path (#21) — the deterministic tier's first real use of a click, where losing the race must reject rather than proceed. Matching has the opposite requirement, so it got its own method. Loosening the shared one would have quietly removed a claim-time guard while appearing to fix an unrelated matching bug.

Also fixed

A parity divergence found alongside: PHP filtered candidates on expires_at > UTC_TIMESTAMP(), Node had no expiry check at all. Harmless while expiry and the match window are the same duration, real the moment either is configured independently.

Ordering for a device's bindings keys on the click's created_at, not matched_at — the latter is written by UTC_TIMESTAMP() into a plain TIMESTAMP, both one-second resolution, so same-second locks tie and resolve in arbitrary storage order.

Verification

The scoring suite structurally could not catch this: the bug was never in scoring, it was in which rows the candidate query could see, and a pure-scoring suite has no database.

tests/run.php now carries a database-backed section — in-memory SQLite with UTC_TIMESTAMP() shimmed via sqliteCreateFunction(), so it still provisions and installs nothing:

  • five reinstall cycles under five different device ids
  • three repeat matches against two competing referrers
  • takeover by a newer qualifying click
  • refusal of takeover by a newer non-qualifying one
  • an unrelated device still matching nothing

25 assertions pass under UTC, Africa/Lagos, America/New_York and Pacific/Chatham. Node: 37/37, typecheck and build clean on all three packages.

Counter-checked by reintroducing only the matched = 0 clause — 7 assertions fail and reproduce both reported symptoms exactly, including the ALICE01BOB0002 flip.

One caveat recorded in both the test comments and decisions #30: "a newer qualifying click takes over" passes even against the buggy code, since the old click is filtered out and the new one wins by default. It passes for the wrong reason there and is not load-bearing on its own.

Full writeup in docs/decisions.md #30.

Closes #17.

/match locked the winning click to the device while the candidate query
only considered unmatched clicks, so a matched click became invisible to
everyone — including the device that had just matched it.

Two user-visible failures, one clause:

- a reinstall recovered nothing. On iOS identifierForVendor is cleared
  when the last vendor app is uninstalled, so the returning device
  presents a new id and could not find its own click.
- a repeat match silently re-attributed the user to a different referrer,
  falling through to the runner-up. Nothing logged an error.

matched now means 'last matched by', not 'used up'. Preventing a code from
being redeemed twice belongs in whatever records signups — the SDK's job
is to report what was actually clicked, not to make the click disappear.

Removing the filter alone would have lost an attribution whenever a
device's signal later stopped clearing the threshold, so an existing
binding is kept unless a newer click qualifies. Compared by the click's
time rather than confidence: a stale click that scores higher is still
wrong, and a newer click below the threshold must not take over or an
unrelated recent click could steal an attribution.

bindMatch() is deliberately separate from lockToDevice() rather than
loosening it — that method's matched=0 predicate is a security guard on
the claim path (#21), and relaxing it would have removed a claim-time
check while appearing to fix a matching bug.

Also fixes a parity divergence found alongside: PHP filtered candidates on
expires_at, Node did not.

Tests: run.php gains a database-backed section (in-memory SQLite,
UTC_TIMESTAMP shimmed, still zero-dependency) — the scoring suite could
never reach this, since the bug was in which rows the query could see
rather than in scoring. Counter-checked by reintroducing only the
matched=0 clause: 7 assertions fail and reproduce both symptoms.

See docs/decisions.md #30.
@Newtdev Newtdev self-assigned this Sep 15, 2026
@Newtdev
Newtdev merged commit 7d31b1a into main Sep 15, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Matching a click consumes it: reinstalls lose attribution, and repeat matches silently re-attribute to a different referrer

1 participant