Referral program: spec

Table of Contents

1. Purpose

The referral program derives a deterministic REF- code per user, registers a referrer for a referee on first touch, credits referrals idempotently per event, walks a reward-tier ladder, and computes a cohort viral coefficient. Namespace: wal-sh.adtech.referral-program.core (cross-host .cljc). JVM-only cohort simulator: wal-sh.adtech.referral-program.server. Browser adapter: wal-sh.adtech.referral-program.browser (window._referral).

ORACLE: src/wal_sh/adtech/referral_program/core.cljc (pure), server.clj (cohort simulator), browser.cljs (window._referral).

2. Requirements

Input contract. Bad inputs throw ex-info.

Field Type Bound
user-id / referrer-id / referee-id / event-id non-empty string ≤ 256 chars no whitespace, no <, >, ", ', `, &, /, \, =
n-refs non-negative finite number coerced to long
cohort fields (:users :invites-sent :invites-accepted) non-neg finite numbers n/a
code (parse) string matching REF-[0-9A-Z]{6} no whitespace anywhere

ex-info :reason values: :invalid-id, :invalid-refs, :invalid-cohort.

Composition order (fixed):

  • register-referrer MUST precede credit for any (referee, event) pair to produce a credit. Re-ordering is not a breaking change; it just means credits silently drop for un-registered referees (negative case 2).

XSS / injection precondition:

  • The browser adapter NEVER calls innerHTML with user-derived data. Widget construction is createElement + textContent + value-setters. The JS source used template-literal innerHTML interpolation; that path is closed in the port. Validated by safe-key at the core boundary AND by structural DOM construction at the browser boundary (defence in depth).

3. Contract signature

Pure (core.cljc):

Fn Signature Returns
referral-code (user-id) "REF-[0-9A-Z]{6}"
parse-referral-code (code) body string OR nil
tier-for (n-refs) tier map OR nil (n < 1)
next-tier (n-refs) tier map OR nil (maxed)
register-referrer (state referrer-id referee-id) state' (first-touch)
credit (state referee-id event-id) state' (idempotent)
credit-count (state referrer-id) non-neg int
referee-count (state referrer-id) non-neg int
viral-coefficient (cohort-map) double ≥ 0 (NaN-free)
report (cohort-map) vector of lines (no IO)
share-url (origin code) string
build-query (code) query-string fragment (no leading ?)
empty-state () {:credits {} :referrers {}}
reward-tiers def ordered vec by ascending :referrals

4. Output guarantees (invariants)

  • referral-code is deterministic and cross-host bit-identical; reuses ab-engine.core/fnv1a-32 per lesson 7. Vector test pins "alice" → "REF-ZPHS9S" on BOTH JVM and CLJS.
  • tier-for is monotone in n-refs (prop-tested).
  • credit is idempotent per (referee, event) (set-add semantics).
  • viral-coefficient returns 0.0 on empty cohort; never NaN, never throws.
  • share-url query order is deterministic: ref first, then UTM keys in alphabetical order via sorted-map. Independent of map impl.
  • register-referrer honours first-touch: a referee gets at most one referrer for life (later registrations are no-ops, not overwrites).
  • parse-referral-code is strict: anything not matching REF-[0-9A-Z]{6} exactly (including length, alphabet, no whitespace) → nil.

5. Negative-state catalog (property tests)

All entries have rejection or no-op tests in test/wal_sh/adtech/referral_program/core_test.cljc (example) and .../core_prop_test.cljc (property) unless noted.

# Input shape Specified behaviour Test
1 self-referral (referrer = referee=) at register-referrer no state change; subsequent credit returns 0 self-referral-rejected (example) + self-referral-never-credits (50 cases)
2 referee with no register-referrer call → credit state returned unchanged; no error referee-with-no-referrer-no-credit (example)
3 duplicate credit for (referee, event) set-add absorbs the repeat; credit-count unchanged credit-fires-once + credit-idempotent (100 cases × 1..20 replays)
4 user-id with whitespace / newline / tab safe-key throws :reason :invalid-id BEFORE hashing → no echo, no leak bad-ids-rejected (100 cases)
5 user-id with <, >, ", ', `, &, /, \, = (injection set) same; safe-key throws bad-ids-rejected (covered by gen)
6 nil / empty / non-string user-id safe-key throws bad-ids-rejected
7 user-id length > 256 safe-key throws (DoS bound) bad-ids-rejected (1024-char gen)
8 n-refs = 0 at tier-for returns nil (no reward) tier-thresholds (example)
9 n-refs < 0 at tier-for throws :reason :invalid-refs tier-rejects-negatives (50 cases)
10 n-refs = ##NaN or ##Inf throws :reason :invalid-refs (finite check) covered by tier-rejects-negatives precondition + manual (rp/tier-for ##NaN) smoke
11 empty cohort {:users 0 :invites-sent 0 :invites-accepted 0} viral-coefficient0.0 (NOT NaN) viral-coefficient-empty-is-zero (50 cases) + viral-coefficient-known-values
12 cohort with :users > 0 but :invites-sent = 0 viral-coefficient0.0 (avoids 0/0) viral-coefficient-known-values
13 cohort with negative or NaN field throws :reason :invalid-cohort viral-coefficient-nonneg (precondition gens stay non-neg; manual smoke confirms throw)
14 first-touch overwrite attempt (B referred A, then C tries to register as A's referrer) no-op; first-touch wins first-touch-wins (example)
15 circular chain (A→B; B→A) each side has 1 credit when both signup-events fire (no infinite recursion, no double-credit) circular-chain-stops (example)
16 referral-code collision across distinct users impossible per FNV-1a + REF: namespace; verified by random gen code-collision-resistant (50 random pairs; theoretical p ≈ user-count²/2³³)
17 malformed code at parse-referral-code (wrong prefix, wrong length, whitespace, non-alphanumeric) returns nil; never throws parse-roundtrip (example, 4 cases)
18 inbound ?ref=<script> in browser adapter check-inbound returns nil; parse-referral-code rejects on the raw string; reconstruction uses validated body only (browser-only; not in JVM test suite; manual XSS audit)

Defspecs (count = 11):

Property Cases
code-deterministic 100
code-shape-always-valid 100
code-parse-roundtrip 100
code-collision-resistant 50 (paired)
bad-ids-rejected 100
tier-monotone-in-refs 100 (paired)
tier-rejects-negatives 50
credit-idempotent 100 (× 1..20 replays)
self-referral-never-credits 50
viral-coefficient-empty-is-zero 50
viral-coefficient-nonneg 100

6. Cross-references

  • ab-engine/spec.org: source of fnv1a-32, reused verbatim for referral-code so the code is bit-identical across hosts.
  • affiliate-engine/spec.org: sibling revenue module with the same ID-validation posture (valid-partner-id? there, safe-key here).
  • ORACLE:
    • src/wal_sh/adtech/referral_program/core.cljc (pure)
    • src/wal_sh/adtech/referral_program/server.clj (JVM cohort simulator)
    • src/wal_sh/adtech/referral_program/browser.cljs (window._referral)
    • test/wal_sh/adtech/referral_program/core_prop_test.cljc (properties)

7. Open questions

  • PLANS.org specced tier thresholds 1/3/25/100; the JS source ships 1/3/5/10/25. The port keeps the JS ladder (observational source of truth) and surfaces both in reward-tiers. Changing thresholds is a one-line config edit + new vector test.