Reputation
One interaction-event stream, two projections. Reputation is the one-to-world projection: domain-scoped trust scores with asymmetric gain/loss physics, lazy exponential decay, and named tiers. Affinity is the one-to-one projection: directed relationship edges between any two entities that climb a configurable state ladder, up to and including a Nemesis suite of rivals that remember.
When to reach for it: marketplace seller trust that gates privileges, community karma that fades without contribution, NPC relationships that follow how a player treats them, trust scores for AI agents.
When not to: plain score rankings with no trust physics belong to Leaderboard; earn-and-redeem points ledgers belong to Loyalty.
Concepts
- Domains are isolated scoring universes (
marketplace,tavern_sim). Each has its own config; any operation in a domain without one fails with404 config_not_found. - Entities are
kind:idpairs; kinds areauth_user,agent,guild, andecs_entity. Anything can rate anything. - Events are the only write. Each event names an actor, a target, and
an
event_typethat must be declared in the domain config'saffinity.event_deltasmap (undeclared:400 unknown_event_type). - Reputation delta = rating x gain-or-loss multiplier x weight x repeat-rater falloff: trust is lost faster than earned, and repeat events from the same actor on the same target count geometrically less.
- Decay is lazy - nothing recomputes in the background. Scores decay by half-life at read time and the tier is re-derived on every read.
- Roles: reads are open to any authenticated caller; plain users may
ingest events only as themselves. Domain config, preference profiles,
nemesis resolution, and quarantine status require the
reputation_grantercapability or a platform admin token.
Configure a domain
Upsert the per-domain rules from a JSON file with reputation and
affinity blocks (requires reputation_granter or admin):
snug reputation config set --domain tavern --file ./config.json
snug reputation config get --domain tavern{
"reputation": {
"gain_multiplier": 1.0, "loss_multiplier": 3.0,
"decay_half_life_days": 30.0, "repeat_rater_falloff": 0.5,
"min_rater_account_age_days": 0,
"velocity_cap": { "max_events_per_rater_per_day": 50 },
"tiers": [ { "name": "untrusted", "min": -1000000.0 },
{ "name": "neutral", "min": 0.0 },
{ "name": "trusted", "min": 5.0 } ]
},
"affinity": {
"neutral_point": 0.0, "decay_toward_neutral_half_life_days": 14.0,
"event_deltas": { "gift_given": 5.0, "insulted": -8.0 },
"states": [ { "name": "nemesis", "max": -50.0, "nemesis_suite": true },
{ "name": "rival", "max": -10.0 }, { "name": "stranger", "max": 10.0 },
{ "name": "friend", "max": 1000000.0 } ]
}
}Tiers carry inclusive lower bounds, relationship states inclusive upper
bounds; empty tier or state lists are rejected with 400 invalid_config.
Setting min_rater_account_age_days or the velocity cap below the
platform floors requires a full admin token, not just reputation_granter.
Send events
snug reputation event send --domain tavern --type gift_given \
--actor auth_user:docs-wave --target auth_user:vendor --rating 5
snug reputation event batch --domain tavern --file ./events.jsonVerified guardrails: a non-privileged caller whose --actor is not their
own auth_user identity gets 403 insufficient_permissions, and so does
rating yourself. Admins and reputation_granter holders may submit events
for any actor, such as an NPC. Negative ratings need the = form:
--rating=-2 (a bare -2 parses as a flag).
The response reports what actually applied: the integrity-damped weight,
the reputation delta, and any tier or relationship transition the event
caused. A batch (events array, capped at 100 by default, else
400 batch_too_large) is validated up front and rejected wholesale -
verified: one unknown type in a two-event batch failed the whole batch
and left the target's rating count unchanged.
Read scores
snug reputation reputation get --domain tavern --entity auth_user:vendor
snug reputation reputation leaderboard --domain tavern --filter 'tier[eq]=trusted' --limit 25The leaderboard ranks by decayed score, best first, and filters on tier.
It is a list that pages by position, so it
refuses total.
Over HTTP the score read is enveloped
(GET /api/v1/reputation/scores/{domain}/{entity_kind}/{entity_id}),
captured live:
{
"status": 200,
"msg": "OK",
"data": {
"score": {
"domain": "docs4-tavern",
"entity": { "kind": "auth_user", "id": "docs4-vendor" },
"score": 4.999986629123192,
"raw_score": 5.0,
"tier": "neutral",
"rating_count": 1,
"unique_raters": 1,
...
}
}
}That capture is the lazy-decay gotcha in action: the ingest response one
second earlier reported a transition to trusted (score 5.0, tier min
5.0), but by read time decay had shaved it to 4.99998 and the tier
re-derived as neutral. Set tier bounds with decay in mind.
An entity nobody has rated is 404 score_not_found - there is no implicit
zero score. --at <RFC3339> recomputes the decayed score as of any
timestamp; verified: a 7.5 score read 30 days out (one half-life) returned
3.75. The leaderboard is delegated to the Leaderboard service and re-ranked
by decayed value at read time.
Anti-abuse
Every event passes integrity checks before it lands: a per-rater-pair
velocity cap, target burst detection, and rater-cluster (collusion)
detection. Tripping a check damps the event rather than rejecting it -
verified with a cap of 2/day: the third event came back
"damped": true, "damping_factor": 0.5, "reasons": ["velocity_cap"] with
its delta quartered by damping and repeat-rater falloff combined. Enough
distinct reasons quarantines the target: events apply at weight zero and a
Human-in-the-Loop review
ticket is opened for the reputation_arbiter approver.
snug reputation quarantine status --domain tavern --entity auth_user:vendorQuarantine status requires reputation_granter or admin; a plain-user
call is 403 insufficient_permissions (verified). Raters below the domain's
min_rater_account_age_days are rejected with 403 rater_too_young.
Tier and relationship transitions
Crossing a tier or relationship boundary fans out (best-effort) to PubSub
on channel reputation.transitions.{domain}.{kind} and to the durable
Timeline stream reputation_transitions_{domain}, where kind is
tier_changed, relationship_changed, or nemesis. Both fanouts were
captured live; subscribing to the channel is subject to the
PubSub channel-capability rules.
Affinity and the Nemesis suite
Directed edges, the state ladder, preference profiles, and nemesis promotion/resolution have their own page: Affinity and nemeses.
Storage and consistency
Domain rules, preferences, scores, affinity/history, unique-rater accounting and retained events live in tenant-scoped PostgreSQL tables through Rent. An event's durable changes commit together; writes within a scoring domain serialize under its parent lock. Different domains remain independent. Capping an edge list keeps the underlying relationship available for direct reads. Event history retains the configured approximate bound.
Redis keeps the expiring integrity windows and messaging. Those windows advance before durable scoring, and transition delivery is best-effort after commit. HITL review creation and its expiring SQL pointer are separate operations; there is no claim of atomicity across services. The delegated leaderboard reads the canonical committed source score before publication.
Limits and configuration
Batch size (100), edge-index and nemesis-memory caps, anti-abuse windows
and thresholds, and the platform floors are tunable via the
REPUTATION_* variables.
Reference
- Reputation API - every endpoint, callable
- Related: Leaderboard serves the rankings, Timeline and PubSub receive transition events, Human-in-the-Loop reviews quarantines
