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:
| Tier | Requires |
|---|---|
| veteran | confirmed sessions ≥ 50 AND account age ≥ 6 months AND distinct manner tags ≥ 20 AND unresolved reports < 2 |
| trusted | confirmed sessions ≥ 20 AND distinct manner tags ≥ 10 AND (attendance rate is null OR ≥ 90%) AND unresolved reports < 3 |
| active | confirmed sessions ≥ 5 |
| sprout | else (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'staggee_idgets 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) — theTypeHerotitle badge (TrustTierBadge) is wrapped in aPressableopening 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 신뢰 등급StatStripcell'sonPressopens the sheet (StatStripCell.onPress,packages/ui/src/stat-strip.tsx— wraps the cell's tile in aPressable variant="row" affordance="layer", the same resting cueEloFactRowuses).
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.