Trust Tier Conventions
Status: Active Last reviewed: 2026-11-02
The player-reputation ladder (sprout → active → trusted → veteran) shown as TrustTierBadge, explained by TrustTierInfoSheet.
Decided, not yet built (the owner, 2026-10-29): the four tiers give way to one 신뢰 점수, 0 to 100, every point a row of 신뢰 기록 (the decision and the point table, § 7). This page describes the code as it runs today; build nothing new on the tier names, and move the parity gate,
computeTrustScoreand the explainer with the score when it is built. Rationale/history live in CLAUDE.md; the machine-enforced subset is AGENTS.mdDATA-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/00667_one_report_pipeline.sql (the latest CREATE OR REPLACE; earlier defs: 00086, 00125, 00444) — 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 reporters < 2 |
| trusted | confirmed sessions ≥ 20 AND distinct manner tags ≥ 10 AND (attendance rate is null OR ≥ 90%) AND unresolved reporters < 3 |
| active | confirmed sessions ≥ 5 |
| sprout | else (the default — just signed up) |
Demotion cap: once ANY of the above assigns trusted/veteran, unresolved reports from 3 or more DISTINCT members force 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 against the player, counted by DISTINCT reporter (00667 — one account repeating a report is one person; before, three rows from one account demoted a 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 club report filed (
report_content) or resolved (resolve_member_report) — 00667; before it, a report changed the tier only at the player's next manner tag or attendance event.
Where reports come from (REPORT-ONE-PIPE, 00667)
A club report (member_reports) is never written by a client. The one filing path is public.report_content (the one ReportModal): every report lands in the platform queue (content_reports), and a report filed from a club's chat or a club session's chat ALSO lands in that club's queue when the reporter and the reported user are both active members there (a message report must name the message's own sender). Club admins read their queue on the 신고 screen and resolve through resolve_member_report (owner/admin of the report's club; resolved_by is the caller). Clients hold SELECT only (RLS: the club's admins); pgTAP one_report_pipeline.test.sql pins the routing, the closed writes and the distinct-reporter count.
A block ends a friendship, silently (00746, F4). public.block_user inserts the user_blocks row and, in the same transaction, deletes the pair's friendship (accepted or pending, either direction) and its request / accepted signals through private.end_friendship_of_pair; nothing is emitted, so the blocked person is told nothing. Unblocking restores nothing: either may send a new request. A block also stops the club's offers: nominate_for_membership, confirm_guest_conversion and invite_club_guest refuse (SQLSTATE P0001, HINT offer_unavailable) when the acting official and the person have a block in either direction (00768, MEM-6; one check, private.assert_offer_available). The words name a block only to the person who made it: the official reads, before the press, that a person they blocked cannot be offered (the row states it; unblocking stays in 설정's 차단 관리), while a refusal reads a reason class that names no block, because the server cannot tell which side blocked and the other side must not learn it. A person who blocked an official is left out of that official's guest suggestions (get_recommendable_guests, 00777). The check runs last, just before the row is written and after every eligibility check, so a person who is ineligible for another reason gets that reason whether or not a block exists. The residue: an official who knows the person is otherwise eligible can infer a block from the refusal; the app does not pretend the offer was sent. An offer already pending is not touched: the person can still answer it and the official can still cancel it. Reports: a club official reads the report rows but never who filed them, for any subject kind (the member_reports column allow-list of 00736, pinned by 00768_report_reporter_closed_every_kind.test.sql). friendships.status is pending | accepted; the legacy blocked value is closed (00746 turned any such row into the user_blocks row it stood for). Past matches.friend_pairs snapshots stay: they are history. The confirm Dialog on the profile and in a DM names the consequence for friends (blockBody(friends)).
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.
The trust score and its ledger (00703, 00707)
Beside the tier, the 신뢰 점수 is computed in SQL on read (private.compute_trust_score): base 50 plus the points of the last 12 months, held 0..100, hidden until the fifth recorded session. The rules live in private.trust_score_rules (attended +1, praise +1, late −1, late_cancel −2, no_show −4, excused 0, free_cancel 0, report_upheld −10; eight active since 00707) and the numbers in private.trust_score_policy (base, window, minimum sessions, the praise cap per giver, late_threshold_minutes = 10). The ledger (private.trust_ledger) unions four sources: attendance records, praise, upheld reports and, since 00707, lateness — whole minutes from rsvps.checked_in_at against the person's own window start (arrives_at, else the session's start in its timezone); a late row is not one of the 20 counted records. get_trust_record(p_user_id, p_club_id) is the one read: the viewer level (self, official, context, stranger) decides what it returns; officials of the club also get lateCount. The tier function recalculate_trust_tier is untouched by the score. Nothing is typed: the stamp is the server's clock (canon/sessions.md § Attendance window).
The excusal request (00754, follow-up batch E)
A member asks for a late cancel or a no-show to be excused (request_attendance_excuse: one of five categories, an optional note of at most 100 characters); an official answers (answer_attendance_excuse); the answerer alone may undo for 10 minutes (undo_attendance_excuse_answer, which returns undoUntil). excuse_attendance_record(uuid) keeps its signature for installed builds and goes through the same accept routine as an official-origin row with the same undo.
- Who answers: one helper.
private.may_answer_attendance_excuse(caller, record)overprivate.attendance_excuse_deciders(record): an active owner or admin of the record's club and, for a 매치, its creator; never the record's own member, by any role (excuse_self). The request-time check, the answer, the undo, the record JSON, the notices' recipients, the 홈 counter and the open-request rule all ask it; a second spelling of "owner or admin of the record's club" in this feature is a defect. A request nobody could answer is refused at request time (excuse_no_decider). - The answer is immediate; the notice is not. An accept sets the record excused at once and recalculates points, rate, strikes, the RSVP block and the tier; the prior status, strike and confirmer are stored on the request, and an undo restores exactly those and reopens the notices the accept closed, sending nothing (the
trust_tier_changednotice is held for both writes by a transaction-local flag only a definer function sets). The requester readspendinguntil the window has closed, and the ticksignals_tick_excuse_answerstells them once after it, only for an answer that stood. The 3-day reminder to the deciders is its own notice type (excuse_request_reminder), sent once. - A 거절 is final (
excuse_declined_final): an official cannot later excuse that record directly. The seven days run from the session's Korea date (day 7 allowed, day 8 refused). - The count is the request table's. The decider sees
excusedCount12m: requests the member made that an official accepted in this club only. Never counted fromfinal_status = 'excused'(the system writes that for host cancels, expired holds and departures), never across clubs, and an official's own direct excusal is not counted. - What an excuse does to the figures: the record reads 사유 인정 with 0 points and no drop-off day; the weighted absences, the RSVP block and the tier are recomputed without the record; the member's open no-show or late-cancel notice is resolved. Re-joining leaves the record excused.
- Privacy. The category and the note can be health information: the
purpose_catalogrowattendance_excuseis sensitive (legal basisconsent); only the requester (the table's policy) and a decider (the record JSON) read them; no signal, push, log, telemetry or persisted cache of a decider's device carries them. A request is deleted when its record leaves the 12 months and with the person's withdrawal, and is in the personal data export. A health reason needs its own consent (00768, PRIV-5):request_attendance_excuse(session, category, note, p_health_consent default false)refusesillness_injuryunless the parameter is true (SQLSTATEP0001, HINTexcuse_health_consent_required, nothing stored), and the row records the moment inhealth_consent_at(NULL for every other category; the triggerexcuse_health_consent_guardon the table refuses a new member-originillness_injuryrow without it, with the same SQLSTATE and HINT, whoever writes it, and refuses any later change of the moment; a stored row is never judged, so an old row can still be answered, reminded and undone). The column is the requester's own data: SELECT is granted toauthenticatedunder the same own-rows policy and the export lists it. Only deciders read the reason; any other official (an owner or admin who is not a decider, a former admin) reads neither category nor note and sees only whether the record was excused. A legal check on the category 아픔, 부상 and the note is the owner's to arrange before launch (docs/release-evidence.md).