Capture the recommendation in AGENTS.md (and mirror it in TRUST_SCORE.md): do not build the cvm-server per-observer GrapeRank scorer yet. The full confidence model is provider-side and consumed via NIP-85; the client has a local confidence gate; the blockers are the per-observer signing key and 8-hop compute plus lifecycle/deploy/monitoring, with ~zero users making the ROI negative. A house-perspective scorer is the cheaper path; revisit when there are users, no trustworthy provider, or skate-specific signals to bake in.
17 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_THRESHOLD2,VERIFIED_RANK_THRESHOLD2).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
TrustBadgeregisters 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 touseTrustAssertions.ts— no call site changed. - Direct follow is neutral.
bucketFromLocalScorenow 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, andTrustBadgesuppresses verified when the viewer directly follows. Flagged is never suppressed. - Flagged notice with a breakdown and actions.
useUserReportsreads the public NIP-56 reports naming an account;components/TrustFlaggedNotice.tsxshows the reporters grouped as people you follow / in your network / other, plus Report (reusesReportDialog) 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
/trustpage. 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, localStorageskatespots:trust:display; Flagged is never hidden), the active provider + relay (useTrustProvider), and provider choice:useSetTrustProviderpublishes a NIP-85 kind-10040 designation (custom npub/hex +wss://relay, all five30382:<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)
useMuteListreads the viewer's NIP-51 kind-10000 mute list plus any optimistic local mutes (lib/localMutes.ts,skatespots:local-mutes-v1).useMuteUserrepublishes kind-10000 with the target added (preserving existing entries), records it locally, and invalidates content queries.- The shared
filterAdminHidden/filterAdminHiddenMixednow also drop authors inhidden.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.useAdminModerationcarries the mute set alongside the admin verdicts (it is the app-wide "drop this content" layer;includeHiddenadmin tooling still bypasses it). - The flagged notice gains a Mute action (Report + Mute + Dismiss).
Fifth pass — network alerts (landed)
useNetworkAlertstakes the viewer's follows, reads their NIP-85 assertions (batched), and surfaces the accounts that trusted people have reported or muted (reporters/mutersat/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 (reusinguseFollowActions,useMuteUser,ReportDialog) — inform, never auto-act.- Settings → Trust links to it with a live count.
Sixth pass — local confidence gate (landed)
- The fallback local ladder now gates a friend-of-a-friend behind a confidence
threshold:
useViewerGraphcounts distinct follows per friend-of-a-friend, andderiveLocalScorereturnsverifiedonly atFOF_MIN_RATERS(2) or more. A lone endorsement no longer reads as trust. - Provider assertions are unaffected: the full GrapeRank confidence model lives
in the provider and we consume it via NIP-85. Running our own scorer is
deferred — see the dated decision in
AGENTS.md("Own GrapeRank-style scorer — DEFERRED"): the per-observer signing key and 8-hop compute are the blockers, and the client is already provider-ready. A house-perspective scorer (one key, one observer) is the cheaper path if ever wanted.
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.tsandsrc/hooks/useBatchTrustScores.tscompute a static ladder from the viewer's follow graph:100self,90direct follow,60friend-of-friend (2 hops),30has a NIP-05,0unknown. Signature-verified viaonlyVerified.src/lib/trustFilter.ts+TrustFilter.tsxexpose the levelsall / verified (>=90) / trusted (>=60) / following.useSpotCommunityScoreweights content signals by distinct accounts, web-of-trust and activity (per-account cap of 3);useTradeIntegrityadds ring detection. Reports driveuseAutoHiddenSpots(>=3 distinct reporters, no community signals) and the review queue.
NIP-85 consumption is half-built:
src/hooks/useRelatrScore.ts(anduseBatchRelatrScores) query kind30382from one hardcoded provider pubkey + relay (auto-enabled), verify signatures, and read only theranktag. It ignores provider designation, thefollowers/reporters/muters/hopstags, and the spec's absence rules.src/lib/relatr.tsis a placeholder REST client, disabled (RELATR_ENABLED = false); it was never finished and is the wrong shape (REST, not events).
Gaps versus the specs
- No provider designation: we neither read the viewer's own kind
10040nor let them choose providers, and we cannot show which provider a score came from. - Negative signals are ignored: mutes (kind
10000) and user-level reports (NIP-561984) are not inputs to an author's score. - 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. - No trust-driven alerts: the app filters content, but never tells a user "someone you follow has been reported by trusted people."
- 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 == providerand 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
hopsmeans "no known path", never0; - 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).
- an absent
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/muterscounts (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/.
- 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
- 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
trustFilteralready 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.tsshould be retired (REST, disabled, unconfirmed);useRelatrScore.tsis the seed to grow from.
Recommended first slice
- 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. KeepuseRelatrScoreworking by expressing it as a provider. - 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 |