Influencer links: spec
Table of Contents
1. Purpose
Influencer links tag a creator × product pair into a tracked URL,
split a sale between creator and platform so the parts sum exactly to
the sale amount, and decide whether a sale falls inside a click's
attribution window. Namespace: wal-sh.adtech.influencer-links.core
(host-neutral .cljc). Browser adapter:
wal-sh.adtech.influencer-links.browser (binds window._influencer
AND window._creatorCommerce for back-compat).
ORACLE: src/wal_sh/adtech/influencer_links/core.cljc (pure),
browser.cljs (window._influencer).
2. Requirements
Data shapes:
- creator
{:creator-id String :handle String :commission-rate Number? :platform String? :storefront String?};:commission-rateis a fraction ∈ [0.0, 1.0]; nil/absent falls back todefault-creator-rate(0.10).- sale
{:amount Number :creator <creator>};:amountis non-negative, finite (NaN/Inf rejected).- click
{:creator-id String :product-id String :session-id String :click-ts Number};:click-tsis millis epoch.- split
{<creator-id-or-platform-keyword> Number}; sums EXACTLY to sale-amount within rounding (cent precision).- tracked-link
{:link-id :short-url :full-url :utm-params}; all user-supplied fields percent-encoded.
I/O domains:
- Core: pure functions of plain Clojure data. No
js/*, nojava.io, no time access, no randomness. - Browser: owns
Date.now(),Math.random(),window.*set!,document.createElement. Only namespace allowed to call any of these.
Decisions worth flagging:
- No
clojure.coreshadowing. Round-2 lesson (apply) and round-3 lesson (gen-class) applied: function names aretag-product,commission-split,attribution-window?, neverapply/split/window. link-idlifted to caller. The JS module built it fromDate.now() + Math.random()inline; the pure core now takes it as input. Deterministic / testable.browser.cljs/gen-link-idis the side-effecting generator.- Closed-interval attribution window. A sale at exactly
(click-ts + window-ms)is IN. The JS used<not<=; we widened by one ms to match the natural "within X hours" reading. Documented; tested bywindow-exact-boundary-is-inclusive. - Missing
:commission-rate→ default (NOT rejected); rate > 1.0 → rejected. Asymmetric on purpose: defaults are forgiving; out-of-range values are impossible (>100% commission is bug, not config). - Remainder-to-last-party for split sum.
creator-cutis round-cents onamount × rate;platform-cutisamount - creator-cut(also rounded). Sum is EXACTLY the sale amount within cent precision. Verified bysplit-sums-to-sale-amount(200 cases). - URL encoding is hand-rolled. Neither
java.net.URLEncodernorjs/encodeURIComponentwould be bit-equal across hosts in edge cases (legacy+-vs-%20; UTF-16 surrogate handling). We percent-encode an allow-list, using UTF-8 bytes fromString.getByteson JVM andTextEncoderin CLJS. The output is deterministic and cross-host equal. Verified byurl-encode-cross-host-parity. - HTML/JS injection is precluded structurally. Any
<,>,',",/,(,)round-trips as%XX; the URL can never contain<script>. Verified byinjection-never-echoed-in-url(100 cases). url-encodeis total.nil→""; never throws.
3. Contract signature
Pure (core.cljc):
| Function | Signature | Pure | Notes |
|---|---|---|---|
tag-product |
[{:creator-id :product-id :platform :link-id}] → link or nil |
yes | caller supplies link-id (Date.now lifted to adapter) |
commission-split |
[sale opts?] → {party amount} or nil |
yes | platform absorbs rounding drift |
attribution-window? |
[click-ts sale-ts window-ms?] → bool |
yes | closed interval; rejects clock-skew |
calc-earnings |
[sales opts?] → {party total} |
yes | silently drops invalid sales |
dedupe-clicks |
[clicks] → clicks' |
yes | last-write-wins per (creator,product,session) |
distribute-cart |
[lines] → {party total} |
yes | alias for calc-earnings on cart |
url-encode |
[s] → String |
yes | RFC-3986 unreserved + UTF-8 %hex |
default-window-ms |
const | n/a | 24h in ms |
Browser surface (browser.cljs): window._influencer (PLANS.org spec)
and window._creatorCommerce (JS-module compat) both bound to the
same object:
{ tag, click, sale, earnings, renderCard, creators, storefronts }
4. Invariants (property tests)
- ∀ sale s :
(= (:amount s) (Σ (vals (commission-split s))))(within cent precision) - ∀ sale s :
(≤ (creator-cut s) (:amount s)) - ∀ click sale w :
(window? click sale w) ⇒ (window? click sale w')forw' ≥ w(monotonic in window size) - ∀ click sale w :
(and (≤ click sale) (≤ (- sale click) w)) ⇔ true dedupe-clicksis idempotent:(= (d (d xs)) (d xs))
5. Failure-mode catalog (negative spec)
Each row is a hostile / malformed input the core MUST reject without
throwing. Verified by core_prop_test.cljc defspecs (named) and
core_test.cljc examples.
| # | Input | Expected behavior | Verified by |
|---|---|---|---|
| 1 | nil creator on a sale |
commission-split → nil; never throws |
split-rejects-nil-creator, nil-creator-always-rejected (50 cases) |
| 2 | creator with no :commission-rate |
effective-rate returns default-creator-rate (0.10); split proceeds |
split-with-default-rate-on-missing |
| 3 | :commission-rate > 1.0 |
commission-split → nil (impossible: >100% is a bug) |
split-rejects-rate-over-one, rate-over-one-always-rejected (50 cases) |
| 4 | :creator-id longer than max-id-length (128) |
tag-product → nil; bounded before any encoding work |
tag-product-rejects-oversized, oversized-id-rejected (25 × random pad) |
| 5 | nil / blank :creator-id / :product-id / :platform / :link-id |
tag-product → nil |
tag-product-rejects-nil-creator, tag-product-rejects-blank |
| 6 | negative sale :amount |
commission-split → nil |
split-rejects-negative-amount, negative-amount-always-rejected (50 cases) |
| 7 | NaN / Inf sale :amount |
commission-split → nil (host-split float-check) |
split-rejects-nan-amount |
| 8 | empty cart [] |
distribute-cart → {}; no error |
distribute-cart-empty, empty-cart-empty-split |
| 9 | creator-id with HTML/JS injection (<script>...</script>) |
percent-encoded in URL; never echoed; struct-level preclusion | tag-product-url-encodes-injection, injection-never-echoed-in-url (100 cases) |
| 10 | clock skew: click-ts > sale-ts |
attribution-window? → false; the sale can't precede the click |
window-rejects-clock-skew, clock-skew-always-false (50 cases) |
| 11 | sale exactly at boundary click-ts + window-ms |
attribution-window? → true (closed interval; documented) |
window-exact-boundary-is-inclusive |
| 12 | negative timestamps | attribution-window? → false |
window-rejects-negative-timestamps |
| 13 | duplicate clicks (same creator+product+session) | dedupe-clicks collapses to one; last-write (freshest :click-ts) wins |
dedupe-same-creator-product-session, dedup-collapses-true-dups, dedup-idempotent (50 cases) |
| 14 | multi-product cart with mixed creators | per-line commission-split aggregated by creator-id; platform line sums across |
distribute-cart-mixed-creators |
| 15 | invalid sales mixed with valid in calc-earnings |
invalid silently dropped (matches JS $0.00 posture); valid still aggregated correctly |
calc-earnings-drops-invalid |
| 16 | url-encode of nil |
returns ""; never throws |
url-encode-cross-host-parity |
Design notes that close negative cases by construction:
- Length-cap (
max-id-length= 128) happens BEFORE URL encoding so pathological IDs are rejected oncount, not after we've iterated every byte. (JS module had no cap; we close that gap.) commission-splitis closed-shape: the only writers of:creator-idand platform-keyword values are the function itself, drawn from the validated creator map. Even an attacker-supplied creator map rejected byvalid-id?cannot get a value into the output;nilis returned first.- Platform absorbs rounding drift, so
Σ split = sale-amountis not approximate; it's the exactround-centsof the original. (Seesplit-handles-roundingexample for the 33.33 × 10% = 3.333 case.) attribution-window?is total: every input tuple returns a bool. Negative numbers, NaN-via-validation, and reversed timestamps all map tofalserather than throwing.- No regex anywhere. The pocket-es tokenizer / round-1
(?si)bug class is structurally precluded. - HTML/JS injection in
creator-idis precluded structurally: the output URL has all<>'"&/():bytes percent-encoded; the propertyinjection-never-echoed-in-urlasserts<script>never appears in the URL regardless of what bytes are in the input. - No reliance on host time / randomness in core. All non-determinism (link-id, click-ts) is the adapter's responsibility; the core's output is reproducible, which is what makes property tests cheap.
6. Cross-references
- affiliate-engine/spec.org: sibling link-tagging module with the same canonicalise-before-echo posture.
- attribution-engine/spec.org: the general attribution ledger
this module's
attribution-window?is a single-touch special case of. - ORACLE:
src/wal_sh/adtech/influencer_links/core.cljc(pure)src/wal_sh/adtech/influencer_links/browser.cljs(window._influencer)test/wal_sh/adtech/influencer_links/core_prop_test.cljc(properties)
7. Open questions
Open negative cases (documented, not currently tested):
- Concurrent click recording (browser, multiple tabs).
swap!onstateis atomic per JS event loop tick; race-condition surface is zero in a single-page browser. Multi-tab would need aBroadcastChannelshared state; out of scope for the port. - Currency unit (always implicit USD-double). No multi-currency
support; mirrors the JS module's
$0.00posture exactly.