Skip to content

Trust Tier Conventions

Status: Active Last reviewed: 2026-09-17

The player-reputation ladder (sprout → active → trusted → veteran) shown as TrustTierBadge, explained by TrustTierInfoSheet. Rationale/history live in CLAUDE.md; the machine-enforced subset is AGENTS.md DATA-25.

The four tiers

public.profiles.trust_tier is one of sprout | active | trusted | veteran (TRUST_TIERS, packages/app/src/domain/entities/trust.entity.ts). The SQL function private.recalculate_trust_tier(p_user_id) — defined in supabase/migrations/00444_information_flow_wave2.sql:366-457 (the latest CREATE OR REPLACE; earlier defs: 00086, 00125) — is the ONE source of truth for the thresholds. Highest-matching tier wins:

TierRequires
veteranconfirmed sessions ≥ 50 AND account age ≥ 6 months AND distinct manner tags ≥ 20 AND unresolved reports < 2
trustedconfirmed sessions ≥ 20 AND distinct manner tags ≥ 10 AND (attendance rate is null OR ≥ 90%) AND unresolved reports < 3
activeconfirmed sessions ≥ 5
sproutelse (the default — just signed up)

Demotion cap: once ANY of the above assigns trusted/veteran, unresolved reports ≥ 3 forces it back down to active — a member is never admin-flagged (reports) at a lower bar than they're locked out of their own next RSVP (the attendance cooldown, docs/canon attendance policy) would be.

What raises it, what lowers it

Raises: confirmed sessions (total_sessions_confirmed), account age, distinct (tagger, tag_type) manner-tag pairs received, a high global_attendance_rate. Lowers: unresolved member_reports rows against the player (the demotion cap above); a stale/low attendance rate keeps a player out of trusted even with enough sessions.

When it recalculates

recalculate_trust_tier re-reads every input fresh (no incremental state) and runs on:

  • a manner-tag INSERT (manner_tags_after_insert_recalc, 00125) or DELETE (00419) — the tagger/taggee's taggee_id gets recomputed immediately.
  • every attendance-affecting write — the attendance-strike triggers (00127, 00132), the dues-overdue strike path (00405), and the current canonical attendance-strike recorder (00640) all PERFORM private.recalculate_trust_tier(p_user_id).

A genuine tier TRANSITION (old ≠ new) emits the trust_tier_changed signal (category trust, severity medium on a demotion / low on a promotion) — see docs/specifications/signal-system.md. The signal already routes to routes.profile (presentation/utils/signal-routes.ts) — tapping the push/inbox row takes the member to their own profile, where the tier (and now the explainer sheet) live.

The parity gate

Postgres cannot import a TypeScript constant and TypeScript cannot read a migration, so the thresholds are necessarily hand-copied into TRUST_TIER_THRESHOLDS (packages/app/src/domain/rules/trust-tier.rules.ts) for the explainer sheet's copy. scripts/check-trust-tier-parity.mjs (run by yarn check, check:trust-tier-parity) locates the LATEST migration still defining private.recalculate_trust_tier, extracts its numeric literals by variable name, and fails loudly if either side disagrees or the SQL shape changed in a way the script can't parse — never silently drifts. Modeled on check-attendance-policy-parity.mjs (same class of drift, same fix shape).

The explainer surface

TrustTierInfoSheet (packages/app/src/presentation/components/trust-tier-info-sheet.tsx) lists all four tiers (reversed, veteran first), highlights the viewer's own tier, and states each tier's requirement as a TEMPLATED sentence (t().trustTierInfo.<tier>Req(...), numbers supplied by describeTrustTierRequirements — never a hand-typed number in the copy) plus a demotion footer note. useTrustTierInfoSheet() mirrors the ELO explainer's useTierInfoSheet() — the same {open, setOpen, show} shape.

Mount points (U-07c, usability audit 2026-09-16 — "the push said the tier changed and no screen could say why"):

  • Own profile (profile-screen.tsx) — the TypeHero title badge (TrustTierBadge) is wrapped in a Pressable opening the sheet whenever the badge is actually visible (sprout renders no badge, so there's never a tappable-but-invisible dead zone).
  • Public profile (public-profile-screen.tsx) — the 신뢰 등급 StatStrip cell's onPress opens the sheet (StatStripCell.onPress, packages/ui/src/stat-strip.tsx — wraps the cell's tile in a Pressable variant="row" affordance="layer", the same resting cue EloFactRow uses).

There is no dedicated "settings screen" trust-tier row — the settings i18n key publicProfile.trustTierLabel (declared in config/i18n/{ko,en}/settings.ts) is consumed only by the public-profile stat cell above.

Markdown remains the source of truth. Run yarn docs:check before handoff.