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-referrerMUST precedecreditfor 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
innerHTMLwith user-derived data. Widget construction iscreateElement+textContent+ value-setters. The JS source used template-literalinnerHTMLinterpolation; that path is closed in the port. Validated bysafe-keyat 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-codeis deterministic and cross-host bit-identical; reusesab-engine.core/fnv1a-32per lesson 7. Vector test pins"alice" → "REF-ZPHS9S"on BOTH JVM and CLJS.tier-foris monotone inn-refs(prop-tested).creditis idempotent per(referee, event)(set-add semantics).viral-coefficientreturns0.0on empty cohort; never NaN, never throws.share-urlquery order is deterministic:reffirst, then UTM keys in alphabetical order viasorted-map. Independent of map impl.register-referrerhonours first-touch: a referee gets at most one referrer for life (later registrations are no-ops, not overwrites).parse-referral-codeis strict: anything not matchingREF-[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-coefficient → 0.0 (NOT NaN) |
viral-coefficient-empty-is-zero (50 cases) + viral-coefficient-known-values |
| 12 | cohort with :users > 0 but :invites-sent = 0 |
viral-coefficient → 0.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 forreferral-codeso 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-keyhere). - 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 ships1/3/5/10/25. The port keeps the JS ladder (observational source of truth) and surfaces both inreward-tiers. Changing thresholds is a one-line config edit + new vector test.