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-rate is a fraction ∈ [0.0, 1.0]; nil/absent falls back to default-creator-rate (0.10).
sale
{:amount Number :creator <creator>}; :amount is non-negative, finite (NaN/Inf rejected).
click
{:creator-id String :product-id String :session-id String :click-ts Number}; :click-ts is 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/*, no java.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:

  1. No clojure.core shadowing. Round-2 lesson (apply) and round-3 lesson (gen-class) applied: function names are tag-product, commission-split, attribution-window?, never apply / split / window.
  2. link-id lifted to caller. The JS module built it from Date.now() + Math.random() inline; the pure core now takes it as input. Deterministic / testable. browser.cljs/gen-link-id is the side-effecting generator.
  3. 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 by window-exact-boundary-is-inclusive.
  4. 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).
  5. Remainder-to-last-party for split sum. creator-cut is round-cents on amount × rate; platform-cut is amount - creator-cut (also rounded). Sum is EXACTLY the sale amount within cent precision. Verified by split-sums-to-sale-amount (200 cases).
  6. URL encoding is hand-rolled. Neither java.net.URLEncoder nor js/encodeURIComponent would 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 from String.getBytes on JVM and TextEncoder in CLJS. The output is deterministic and cross-host equal. Verified by url-encode-cross-host-parity.
  7. HTML/JS injection is precluded structurally. Any <, >, ', ", /, (, ) round-trips as %XX; the URL can never contain <script>. Verified by injection-never-echoed-in-url (100 cases).
  8. url-encode is 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') for w' ≥ w (monotonic in window size)
  • ∀ click sale w : (and (≤ click sale) (≤ (- sale click) w)) ⇔ true
  • dedupe-clicks is 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-splitnil; 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-splitnil (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-productnil; 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-productnil tag-product-rejects-nil-creator, tag-product-rejects-blank
6 negative sale :amount commission-splitnil split-rejects-negative-amount, negative-amount-always-rejected (50 cases)
7 NaN / Inf sale :amount commission-splitnil (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 on count, not after we've iterated every byte. (JS module had no cap; we close that gap.)
  • commission-split is closed-shape: the only writers of :creator-id and platform-keyword values are the function itself, drawn from the validated creator map. Even an attacker-supplied creator map rejected by valid-id? cannot get a value into the output; nil is returned first.
  • Platform absorbs rounding drift, so Σ split = sale-amount is not approximate; it's the exact round-cents of the original. (See split-handles-rounding example 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 to false rather than throwing.
  • No regex anywhere. The pocket-es tokenizer / round-1 (?si) bug class is structurally precluded.
  • HTML/JS injection in creator-id is precluded structurally: the output URL has all <>'"&/(): bytes percent-encoded; the property injection-never-echoed-in-url asserts <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! on state is atomic per JS event loop tick; race-condition surface is zero in a single-page browser. Multi-tab would need a BroadcastChannel shared state; out of scope for the port.
  • Currency unit (always implicit USD-double). No multi-currency support; mirrors the JS module's $0.00 posture exactly.