SkateSpots/TRUST_SCORE.md
SkateSpots 18a9e28f0b feat(trust): network alerts for reported accounts you follow
Fifth slice of TRUST_SCORE.md. useNetworkAlerts reads the viewer's NIP-85
assertions for the accounts they follow and surfaces those trusted people
reported or muted. New /alerts page lists them with Ignore / Unfollow /
Mute / Report (reusing useFollowActions, useMuteUser, ReportDialog) —
inform, never auto-act. Settings -> Trust links to it with a live count;
ignored accounts are device-local. /alerts added to the smoke list.
2026-10-09 18:32:47 +07:00

16 KiB

Trust scores — ideas and plan

Status: first slice landed; the rest is proposal. No breaking changes. Scope: better trust for people and content across the map, The Lab, the marketplace, and moderation.

First slice (landed)

  • src/lib/nip85.ts — kind-10040 designation parsing, provider resolution with a house fallback, kind-30382 assertion parsing (with the absence rules baked in), and batch parsing keyed by subject.
  • src/hooks/useTrustAssertions.ts — batch read from the resolved provider; signature-verified and author-checked. Absence yields no entry (= unknown).
  • src/lib/trustLadder.ts — the three buckets and the two thresholds (FLAGGED_REPORTERS_THRESHOLD 2, VERIFIED_RANK_THRESHOLD 2).
  • src/hooks/useTrustBucket.ts — combines a NIP-85 assertion (verify + flag) with the local ladder (verify only) into one bucket.
  • src/components/TrustBadge.tsx — now renders Verified / Unknown / Flagged (glyph + colour + tooltip). Unknown is hidden by default; Flagged always shows. Because this is used by SpotCard, TourCard, EventCard, MarketplaceItemCard, ListingDetailPage and CoachDetailPage, the mapping, Lab and marketplace all get it for free.

All three follow-ups landed in the later passes below: the batched read, the flagged notice's reporter breakdown, and Settings → Trust.

Second pass (landed)

  • Batched provider read. Every TrustBadge registers its subject into a shared per-viewer union and ONE query fetches the whole union (no per-card query); a list that grows triggers a single debounced refetch. Contained to useTrustAssertions.ts — no call site changed.
  • Direct follow is neutral. bucketFromLocalScore now verifies only a friend-of-friend (60); a direct follow (90) or self (100) shows nothing, since the viewer already knows them. A provider assertion can still verify a non-followed account, and TrustBadge suppresses verified when the viewer directly follows. Flagged is never suppressed.
  • Flagged notice with a breakdown and actions. useUserReports reads the public NIP-56 reports naming an account; components/TrustFlaggedNotice.tsx shows the reporters grouped as people you follow / in your network / other, plus Report (reuses ReportDialog) and Dismiss (session-only, so the safety signal returns). Wired into the listing seller card and the coach detail page.

Third pass — legibility (landed)

  • Attribution. The badge tooltip names the source of the claim (your network / the community provider / the provider you chose) and links to an explainer.
  • Public /trust page. Explains the three states, that absence is not distrust, who computes trust, presence-is-not-endorsement, and point-of-view relativity. Added to the smoke-test route list.
  • Settings → Trust (?section=trust, components/TrustSettingsCard.tsx): a show/hide-marks toggle (useTrustDisplay, localStorage skatespots:trust:display; Flagged is never hidden), the active provider + relay (useTrustProvider), and provider choice: useSetTrustProvider publishes a NIP-85 kind-10040 designation (custom npub/hex + wss:// relay, all five 30382:<type> rows) or clears it to fall back to the community provider — a signed action, so it prompts the signer. Link to /trust.

Fourth pass — mute (landed)

  • useMuteList reads the viewer's NIP-51 kind-10000 mute list plus any optimistic local mutes (lib/localMutes.ts, skatespots:local-mutes-v1).
  • useMuteUser republishes kind-10000 with the target added (preserving existing entries), records it locally, and invalidates content queries.
  • The shared filterAdminHidden / filterAdminHiddenMixed now also drop authors in hidden.mutedPubkeys, so every list hook that already filters hidden content (spots/shops/events/tours/crews/marketplace/skaters/coaches/lab) hides muted accounts with no per-hook changes. useAdminModeration carries the mute set alongside the admin verdicts (it is the app-wide "drop this content" layer; includeHidden admin tooling still bypasses it).
  • The flagged notice gains a Mute action (Report + Mute + Dismiss).

Fifth pass — network alerts (landed)

  • useNetworkAlerts takes the viewer's follows, reads their NIP-85 assertions (batched), and surfaces the accounts that trusted people have reported or muted (reporters/muters at/above the flagged threshold). Ignored accounts are remembered device-locally (skatespots:trust:alerts-ignored).
  • /alerts (pages/NetworkAlertsPage.tsx) lists them with Ignore / Unfollow / Mute / Report actions (reusing useFollowActions, useMuteUser, ReportDialog) — inform, never auto-act.
  • Settings → Trust links to it with a live count.

Still open: the confidence-gated scoring model.

Source material: the GrapeRank and Trusted Assertions specifications in the NosFabrica/protocols repository, plus a public reference implementation and client by the same group. NIP-85 (Trusted Assertions) is the published, stable contract; GrapeRank is one pre-NIP scoring model that produces the published scores. We should treat NIP-85 as the interface and GrapeRank as a model we may adopt or borrow from, not a hard dependency.

Where we are today

Positive signals (per-viewer, client-side):

  • src/hooks/useTrustScore.ts and src/hooks/useBatchTrustScores.ts compute a static ladder from the viewer's follow graph: 100 self, 90 direct follow, 60 friend-of-friend (2 hops), 30 has a NIP-05, 0 unknown. Signature-verified via onlyVerified.
  • src/lib/trustFilter.ts + TrustFilter.tsx expose the levels all / verified (>=90) / trusted (>=60) / following.
  • useSpotCommunityScore weights content signals by distinct accounts, web-of-trust and activity (per-account cap of 3); useTradeIntegrity adds ring detection. Reports drive useAutoHiddenSpots (>=3 distinct reporters, no community signals) and the review queue.

NIP-85 consumption is half-built:

  • src/hooks/useRelatrScore.ts (and useBatchRelatrScores) query kind 30382 from one hardcoded provider pubkey + relay (auto-enabled), verify signatures, and read only the rank tag. It ignores provider designation, the followers / reporters / muters / hops tags, and the spec's absence rules.
  • src/lib/relatr.ts is a placeholder REST client, disabled (RELATR_ENABLED = false); it was never finished and is the wrong shape (REST, not events).

Gaps versus the specs

  1. No provider designation: we neither read the viewer's own kind 10040 nor let them choose providers, and we cannot show which provider a score came from.
  2. Negative signals are ignored: mutes (kind 10000) and user-level reports (NIP-56 1984) are not inputs to an author's score.
  3. No confidence gate: a single follow instantly reads as 90. A cluster of accounts that only follow each other is indistinguishable from a well-connected account. This is the biggest sybil hole in the current ladder.
  4. No trust-driven alerts: the app filters content, but never tells a user "someone you follow has been reported by trusted people."
  5. Display is a bare ladder: no viewer choice in how (or whether) trust is shown, and no distinct "flagged" state next to "verified" and "unknown".

Ideas, highest value first

1. Make the NIP-85 consumer spec-correct

Replace the hardcoded single-provider lookup with a real consumer:

  • Read the viewer's kind 10040 designation (rows are ["30382:<tag>", <provider pubkey>, <relay>]) and resolve the provider and relay per assertion type. Fall back to a configured "house" provider (the community default) when the viewer has no designation or is logged out.
  • Verify event.pubkey == provider and the signature before use (the signature check already exists; the author check is the trust boundary).
  • Read the normative tags: rank (0-100), followers, reporters, muters, hops. Treat producer extensions as optional.
  • Enforce the honesty rules the spec is emphatic about:
    • an absent hops means "no known path", never 0;
    • absence of an assertion is not distrust;
    • ranks from different observers or providers are not comparable;
    • presence of an assertion is not endorsement (read the counts).

Sketch of the shape: src/lib/nip85.ts (designation parse, provider resolve, assertion parse, verification) + src/hooks/useTrustAssertions.ts (batch read via querySkateContent/relay hints, disabled when no provider). useRelatrScore.ts becomes one configured provider behind that interface rather than a special case. A Settings section ("Trust providers") shows the active provider + relay, allows choosing the house provider or a personal one, and can be switched off.

This alone turns trust from our fixed 0/30/60/90/100 into "whatever provider the viewer chooses", with no new infrastructure.

2. Three buckets and a viewer display mode

Adopt a simple, honest presentation on top of whatever score source is active:

  • Verified / Unknown / Flagged, with color and a glyph so the state is legible without color. "Flashy intermediates" (highly verified vs verified) were deliberately rejected — actionable, not graded.
  • Flagged = verified reporters >= threshold, where "verified reporters" are reporters who themselves clear the trust bar. Not a raw report count. Show who reported, grouped as people you follow / verified / others, never what the account "is".
  • Let the viewer choose how it renders: number / level / word / off. "Off" is a legitimate choice for people who do not want others graded; safety warnings (flag/report) remain a separate channel and always show.
  • Reuse the same ladder in the trust filter, author badges on the map/Lab/ marketplace, and the community-stats page, so a score reads identically everywhere.

Where it lands: a small lib/trustLadder.ts (bucket + ladder helpers), a viewer preference stored per account (the useLocalStorage pattern), and reuse of the existing Filter UI. Content that is Flagged can keep our current soft behaviour (deprioritize, badge, and feed the review queue) rather than hard delete.

3. Trust-driven alerts for counterparties

The alert pattern is a strong fit for a marketplace of strangers and paid Lab services:

  • Compute "accounts in your network that trusted people report/mute" from the provider's reporters / muters counts (or locally, from reports weighted by reporter trust).
  • Surface it as a dismissible banner plus a page of alerts, with actions ignore / unfollow / mute / report and a short interaction history (your follows, prior trades, prior reports). One row per account; a "new since you last looked" marker.
  • The posture is "inform, then let the user decide" — never auto-hide a seller or coach on a single report. This complements the existing reputation cards and the >=3-distinct-reporter auto-hide, not replace them.

4. Confidence-gated scoring

The single most important algorithmic idea from the specs: trust is influence = average x confidence, with confidence = 1 - rho^input and each edge weighted by the rater's own influence and an attenuation factor. A lone endorsement scores near zero; breadth of already-trusted raters is what accumulates. Defaults: rho 0.5, attenuation 0.85, follows +1, mutes and reports negative.

Two ways to get it, in order of preference:

  • Consume it from a provider (the house provider, or a personal one) — no client cost, and the score is already published as a signed assertion.
  • Port it into cvm-server/ (it is ~a dozen parameters and a bounded iteration) and publish our own assertions only if we decide to run a provider. This keeps the heavy work off the phone while staying inside our existing service.

Either way, the win is: stop treating one follow as trust. Our existing per-account cap and activity weighting in useSpotCommunityScore are the same instinct applied to content; this applies it to people.

5. Presets and negative signals as policy

  • Expose scoring policy as named presets (Relax / Default / Strict) that move the follower/muter/reporter cutoffs together, rather than hardcoded constants. If we ever run a provider, persist presets server-side with an append-only change history and show the active one in Settings.
  • Add mutes (10000) and user-level reports (1984) as negative edges — weighted, so a report from a trusted skater counts and a report from a brand-new account does not. We already gather reports; this is where they should feed a person-level signal, not only content moderation.

6. Vocabulary and honesty copy

Borrow the precise wording these specs insist on, because it matches our own convention ("Conditions · weather only", "attested, not verified"):

  • counts are "verified followers / reporters / muters" (trusted raters only);
  • "No one in your network has vouched for this account yet." for Unknown;
  • "presence is not endorsement", "absence is not distrust";
  • "scores are point-of-view-relative" — always attribute a score to a provider and a perspective, never present it as an objective fact.

What we are not doing

  • Standing up the reference infrastructure (graph store + cache + search engine
    • Java worker + Python orchestrator + bundled relays). It contradicts the project's zero-server stance for this feature and is unnecessary if we consume a provider. At most, a provider we operate would live in cvm-server/.
  • Adopting an external search relay as a dependency. The portable idea is ranking our own search and Lab results by the viewer's trust, which the trustFilter already gestures at.

Caveats

  • Licensing. The reference implementation, server and client are AGPL-3.0. Reading the specifications and reimplementing the algorithm is fine; copying their code into our shipped client would pull AGPL obligations onto SkateSpots. Prefer the specs and our own implementation.
  • Spec status. GrapeRank and Trusted Assertions are pre-NIP drafts; NIP-85 itself is published. Code against NIP-85 (10040 / 30382) and treat GrapeRank as one producer.
  • Privacy. The current client-side ladder fetches only the viewer's own follows and keeps the graph local. Consuming a provider discloses "this viewer designates this provider, score this subject"; operating a provider means someone holds the whole graph. Whatever we pick must be disclosed on /privacy, the way geocoding and the AI proxy already are.
  • Existing code. lib/relatr.ts should be retired (REST, disabled, unconfirmed); useRelatrScore.ts is the seed to grow from.
  1. Spec-correct NIP-85 consumer (src/lib/nip85.ts + src/hooks/useTrustAssertions.ts), with kind-10040 resolution, a configured house provider, author + signature verification, and the four normative tags. Keep useRelatrScore working by expressing it as a provider.
  2. A Flagged bucket fed by verified reporter counts, shown with a glyph in the trust filter and on author badges, with a "who reported" breakdown.

This reuses code we already have, needs no new infrastructure, and improves the map, Lab, marketplace and moderation without changing the scoring model. The confidence model (idea 4) and alerts (idea 3) are the natural next steps.

Touch points

Concern File(s)
NIP-85 designation + assertion parsing new src/lib/nip85.ts
Assertion read hook (batch) new src/hooks/useTrustAssertions.ts
Current single-provider score src/hooks/useRelatrScore.ts
Placeholder REST client (retire) src/lib/relatr.ts
Local ladder (keep, or blend) src/hooks/useTrustScore.ts, src/hooks/useBatchTrustScores.ts
Bucket/ladder helpers + viewer mode new src/lib/trustLadder.ts
Filter levels src/lib/trustFilter.ts, src/components/TrustFilter.tsx
Author badges map / Lab / marketplace author surfaces
Alerts new alerts page + banner; report hooks already exist
Policy presets Settings; server-side only if we run a provider
Privacy disclosure /privacy