Skip to content

Reputation

Try the interactive example ↓

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 with 404 config_not_found.
  • Entities are kind:id pairs; kinds are auth_user, agent, guild, and ecs_entity. Anything can rate anything.
  • Events are the only write. Each event names an actor, a target, and an event_type that must be declared in the domain config's affinity.event_deltas map (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_granter capability 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.json

Verified 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 25

The 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:vendor

Quarantine 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

Interactive example

Track trust within a domain using feedback, repeated-rating rules, and decay over time. Map scores to configurable tiers for use elsewhere in your app.

An illustrative example that runs in your browser. No account or API calls required.