morphit/docs/adr/0038-reputation-hardening.md

362 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ADR-0038 — Reputation hardening campaign: time decay + concentration detector + verifiable receipt + side distinction + dormancy signal (cp123cp125)
**Status:** Accepted (shipped 2026-05; pre-launch hardening campaign)
**Date:** 2026-05-23
**Deciders:** project maintainer (Ken)
**Related:** ADR-0009 (order posting, Signal A/B framing), ADR-0014
(chat-and-counterparty-reputation, verified-chat badge), Part 113
audit (reputation attack-surface enumeration, 2026-05-10).
## Context
Pre-cp123, Morphit shipped a comprehensive sybil-resistant reputation
system audited in Part 113 (2026-05-10) across 15 attack vectors
(A1A10 inflation, B1B5 deflation, C1C2 identity, D1D3
aggregation). Of those 15, **4 remained open**:
| # | Vector | Part-113 status |
|---|---|---|
| **A4** | Signal B evasion via diversification | RESIDUAL — indirectly mitigated by A5 economics |
| A6 | Trade never happened (off-chain settlement) | STRUCTURALLY UNDECIDABLE |
| **D3** | Time decay / stale reputation | DEFERRED |
| D1 | New trader cold-start penalty | DESIGN CHOICE — `is_new_trader` badge |
Ken's cp123 ask ("make sure reputation scores cannot be spoofed,
faked, artificially pumped … real, verified feedbacks, successful
trade counts, factor in as many variables as we need to ensure
provability of one's reputation score") re-opened the deferred and
residual items, and added a new explicit requirement: **provability**.
Eight hardening opportunities were enumerated (H1H8). Ken locked
in **H1 + H2 + H4 + H5 + H6**, skipping H3 (reviewer-credibility
weighting — too punitive for newcomers) and H7/H8 (asymmetric-feedback
signal and operator dashboard — lower-value).
## Decision
Five coordinated changes, shipped across cp123 (foundation) → cp124
(surfaces) → cp125 (docs).
### H1 — Time-decay weighting (closes D3)
The published `weighted_rating` becomes a **365-day-half-life
exponential-decay weighted average** instead of a flat AVG. Every
feedback row's contribution is scaled by:
```
weight(age_days) = 0.5 ^ (age_days / 365)
```
- Review today: weight 1.0
- Review 1 year old: weight 0.5
- Review 2 years old: weight 0.25
- Review 3 years old: weight 0.125
The aggregate becomes `SUM(rating × weight) / SUM(weight)`, computed
in SQL at the 3 aggregation sites (`apps/indexer/src/api/feedback.ts`
summary, `apps/indexer/src/api/orderbook.ts`,
`apps/indexer/src/api/orderbookStream.ts`).
**Rationale for exponential decay (rather than linear or step):**
- Exponential has the **memoryless property** — the ratio of any
two reviews' weights depends only on their AGE DIFFERENCE, not
their absolute ages. The formula is stable under clock skew
between indexers.
- Linear decay has a "fall off a cliff" date after which reviews
count zero, creating perverse incentives to game the cutoff.
- Step decay (e.g., reviews older than 1 year count zero) makes
the boundary a target for manipulation.
**Rationale for 365-day half-life:**
- A year is the natural "long enough ago that things might have
changed."
- Shorter (180d) penalizes seasonal traders too harshly.
- Longer (730d) defers the decay benefit unhelpfully far.
- At 365d, a 5-year-old review is worth ~3% of a fresh review —
effectively forgotten but not erased.
**Rationale for SUM(weight) denominator (not COUNT):**
- A trader with 10 fresh 5-stars should rank above one with 100
ancient 5-stars at the same numeric weighted_rating. Both
would otherwise land at 5.00 (every row rating=5, COUNT
denominator).
- The SUM(weight) denominator means fresh contributors get full
influence; ancient contributors get partial.
**Raw COUNT preservation:** The `count` field is unchanged (raw
total). Only `weighted_rating` carries decay. By-rating histogram
unchanged (per-bucket raw).
### H2 — Signal D: review-concentration detector (closes A4 residual)
Signal B requires `distinct_subjects=1` (the reviewer reviewed
ONLY the target). A smart attacker reviews 2-3 throwaway third
parties to evade Signal B while still pumping the primary target.
**Signal D** triggers when a reviewer concentrates ≥80% of their
reviews on a single high-star target across a 30-day window:
- Minimum 5 total reviews in the 30-day window (noise floor)
- Concentration ≥ 80% on one subject
- Avg rating to dominant subject ≥ 4.5 stars (inflation case;
deflation cases captured by Signal C)
- Pair stored in new `review_concentration (reviewer,
dominant_subject)` table; PK on the pair
**Aggregation filter** updated in all 3 sites to exclude rows
where `(reviewer, subject)` matches a `review_concentration` row.
**Scheduling:** runs hourly from `poller.maybeRunSignals` alongside
Signals A/B/C. Same advisory-not-dispositive treatment, same
`ON CONFLICT DO NOTHING` semantics, same operator-side false-
positive recovery (DELETE row).
### H4 — Verifiable reputation receipt endpoint
New endpoint `GET /v1/accounts/:account/reputation-receipt`. The
"show your work" endpoint. Returns the FULL set of inputs that go
into the published weighted_rating so any third party can re-derive
the score locally.
Response fields:
- `account`, `as_of` (ISO), `decay_half_life_days` (365), `formula`
(human-readable string describing both the math AND the
exclusion rules)
- `summary` with `count_total`, `count_included`, `count_excluded`,
`weight_sum`, `weighted_rating`
- `rows[]` — every feedback row about the subject, including
excluded ones, with per-row fields: `source_trx_id`, `reviewer`,
`rating`, `created_at`, `order_permlink`, `age_days`,
`decay_weight`, `included`, `excluded_reason`
`excluded_reason` is one of: `null` (counted), `no_order_permlink`,
`suspicious_reciprocity`, `related_accounts`, `one_way_pile_on`,
`review_concentration`.
**Provability path:** A reader can fetch the chain feedback ops
for an account, apply the documented exclusion rules, run
`computeWeightedRating()` (same function the indexer uses,
exported from `apps/indexer/src/indexer/reputation/decay.ts`),
and verify the published score. Without this endpoint, "provable
reputation" requires running an indexer. WITH this endpoint,
"provable reputation" requires only the ability to read the
chain.
**`as_of` parameter:** Optional ISO timestamp. Used for
deterministic comparison and archival re-verification. **Honest
limitation:** signal-table flags are always evaluated at REQUEST
time (no historical flag-state reconstruction). Two indexers with
different signal-table states will produce different receipts —
this is intentional transparency.
**Caching:** ETag + Cache-Control: 60s.
### H5 — Buy/sell side distinction
The feedback summary endpoint now returns `by_side: { buy: {count,
weighted_rating}, sell: {count, weighted_rating} }` alongside the
single conflated `weighted_rating`. Computed via SQL `FILTER (WHERE
side='buy')` / `FILTER (WHERE side='sell')` clauses after JOINing
feedback to its cited order's side.
**Rationale:** A trader great as a buyer but careless as a seller
(or vice versa) deserves to be visible to readers. The previous
single-number rating conflated both roles.
**UI:** profile page renders separate chips for each side when
that side has count > 0. Hidden gracefully when only one side has
history.
### H6 — Dormancy signal (last_traded_at)
The feedback summary endpoint now returns `last_traded_at` (ISO or
null). Computed as `GREATEST(MAX(orders.created_at WHERE
account=$1 AND fee_status='verified'), MAX(feedback.created_at
WHERE subject=$1))`.
**UI:** profile page renders "Last traded: N ago" using the
existing `RelativeTime` component. Hidden when null (account never
posted a verified order AND never received feedback).
**Rationale:** A trader with great old reviews who hasn't traded
in 18 months may no longer hold their key. Visible freshness
informs trust without changing the numeric score.
### H7 — Composite reputation score (cp404)
Order cards show TWO distinct trust signals side by side: the raw
trade **count** (`feedback_count`, e.g. "852" / "1.4K") and a 05
**reputation score** (e.g. "4.06"). Prior to cp404 the only numeric
was `weighted_rating` (the H1 time-decayed mean), which answers
"what's the average rating" but not "how much should I trust this,
accounting for how much history exists and whether the trader
earned it." The reputation score is that composite, computed by
`apps/indexer/src/indexer/reputation/score.ts` from the SAME
sock-puppet-filtered feedback (H2/Signal-B/C/D exclusions already
applied):
```
base = (n·avg + K·μ) / (n + K) # Bayesian shrink
bonus = BONUS_MAX · exp_frac · rec_frac · aboveNeutral
score = clamp(base + bonus, 0..5)
```
- `avg` = the H1 time-decayed mean rating; `n` = included count.
- **Bayesian shrinkage** toward a neutral prior (`μ`=3.0, `K`=4)
so a newcomer with one glowing (possibly fake) review can't spike
to 5.0 — trust is earned as good trades accumulate and the shrunk
mean rises toward the true high average.
- `exp_frac = ln(1+n)/ln(1+40)` (experience, saturates at 40 trades);
`rec_frac = 0.5^(days_since_last_feedback/180)` (recency).
- **The bonus gate** `aboveNeutral = max(0, (baseμ)/(5μ))` is the
key fairness property: it is **zero at or below the neutral prior**,
so experience and recency can only reward a trader whose rating is
already above neutral, and never rescue a poor or mediocre one. A
500-trade scammer rated 2.0 stays ~2.0; a 5-star veteran climbs
toward 5.0.
Behaviours (locked by `reputation-score-smoke`, 10 scenarios): zero
feedback → `null` (card shows nothing; the 🌱 new-trader chip signals
newness instead); one 5-star → ~3.4, not 5; 200 recent 5-stars → ≥4.8;
more good trades strictly raise the score; a dormant good trader
scores below an active one; always bounded to [0, 5].
**Constants are tunable** (`REPUTATION_PRIOR_MEAN`,
`REPUTATION_PRIOR_WEIGHT`, `REPUTATION_EXPERIENCE_FULL`,
`REPUTATION_RECENCY_HALF_LIFE_DAYS`, `REPUTATION_BONUS_MAX`) — a
future instance could expose them via env like the H1 half-life.
**Transparent + verifiable:** the H4 receipt endpoint now also
returns the score plus its factor breakdown (`reputation_score`,
`reputation_base`, `reputation_bonus`, `reputation_experience_frac`,
`reputation_recency_frac`) and an extended `formula` string, so any
reader can re-derive the "⭐ 4.06" from the raw feedback rows — the
same "show your work" posture as the underlying `weighted_rating`.
## Privacy posture
Per Morphit's standing priority #1 (privacy):
1. **No new on-chain data.** All changes operate on already-chain-
anchored feedback ops + locally-derived signal tables. No new
custom_json types. No new fields on existing ops.
2. **Receipt endpoint reveals which (X, Y) pairs are flagged** —
but this is already implicit in the missing rows of X's
published aggregate. No new privacy leak.
3. **The receipt does NOT reveal flags for pairs (Y, Z) that don't
involve X.** Each account's receipt is scoped to its own
relationships.
4. **Signal D detector reads existing tables only.** No new
chain-data inputs.
5. **`as_of` parameter is caller-controlled.** No information
leak in either direction.
## Decentralization posture
Per Morphit's priority #2:
- **Per-instance signal-table state** is the same posture as the
pre-existing Signals A/B/C. Two indexers can show slightly-
different scores based on their local signal-table state. The
receipt endpoint surfaces this disagreement explicitly when
comparing receipts across instances.
- **No new federation-wide constants.** Half-life is per-indexer
configurable (future enhancement); default 365 days hardcoded.
- **No new operator-mandatory actions.** Existing operators
upgrade and the new aggregation logic + Signal D detector run
automatically.
## Operator action required
- **None mandatory.** Schema migration adds one new table
(`review_concentration`) idempotently via `CREATE TABLE IF NOT
EXISTS`. Existing instances pick this up on next indexer
restart.
- **Optional:** operators may DELETE rows from `review_concentration`
to clear false-positive flags. Same recovery path as the
existing signal tables.
## Performance posture
- **Decay computation** adds ~2 POWER + 2 EXTRACT calls per
feedback row in 3 aggregation sites. Postgres handles these as
scalar functions on small per-row inputs; cost is negligible
against the existing GROUP BY scan.
- **Signal D detector** is hourly; runs alongside Signal C with
the same query-cost profile (CTE + JSONB-free).
- **Receipt endpoint** is O(N) in feedback rows about the
subject. A subject with 10,000 reviews produces a 10,000-row
receipt. 60s ETag caching mitigates repeat-call cost.
## Consequences
**Positive:**
- D3 (time decay) and A4 (Signal B evasion via diversification)
both closed.
- Provability gained: any chain-readable party can verify the
published score without trusting any indexer.
- Buy/sell asymmetry visible to readers (H5).
- Dormancy visible to readers (H6) — no numeric change to the
score, just freshness context.
- 2-decimal precision throughout (server already at NUMERIC(3,2);
UI was truncating to 1).
**Negative / accepted tradeoffs:**
- **Score now requires NOW()-dependent computation.** Cross-
indexer determinism limited by clock skew. Mitigated by
`as_of` parameter for explicit comparison.
- **Receipt endpoint is heavier than the existing summary** —
~1 KB per feedback row. Acceptable; transparent reputation
outweighs payload size.
- **Stale 5-star reviews now matter less** — some long-time
traders may see their headline number drop slightly until they
trade again. This is the intended behavior; raw COUNT is
preserved separately so historical context isn't lost.
- **Signal D can false-positive** on a real trader who happens
to have many repeat trades with one counterparty (e.g., a
regular trading partner). Mitigation: 5-review noise floor +
operator DELETE recovery + the dominant-subject pair flagging
semantics (only the pair is flagged, not the reviewer
globally).
## Honest limitations
- **A6 remains structurally undecidable.** Settlement is off-chain
by design; no software can verify that a trade actually
happened. Reputation is built on the PATTERN of consistent
verified-fee orders + mutual feedback over time, not on any
individual claim.
- **D1 (cold start) remains a design choice.** Brand-new accounts
still get the `is_new_trader` orderbook badge until they have
4+ verified-fee trades. The H1 decay benefits old-trader
recency but doesn't accelerate newcomer onboarding.
- **A10 (stolen private key) remains out of scope.** User opsec
problem; reputation system cannot defend against a compromised
key.
## Related
- ADR-0009 §5 (Signal A/B framing — Part 113 closure context)
- ADR-0014 Component C (verified-chat badge framing)
- `apps/indexer/src/indexer/reputation/decay.ts` (the new
shared formula module)
- `apps/indexer/src/indexer/reputation/score.ts` (H7 composite
reputation score — cp404)
- `apps/indexer/src/api/reputationReceipt.ts` (H4 endpoint; cp404
extends its summary with the score breakdown)
- `apps/indexer/scripts/reputation-decay-smoke.ts` (13 scenarios)
- `apps/indexer/scripts/reputation-score-smoke.ts` (10 scenarios —
cp404)
- `apps/indexer/scripts/reputation-receipt-shape-smoke.ts`
(7 scenarios)
- `docs/faq/how_to_build_high_reputation.md` (cp125 companion FAQ
— Ken's explicit ask: "make sure an faq article explains the
best ways to get yourself a high reputation score"; updated for
the cp404 composite score)