This document describes the Web of Trust graph data model extensions for the ORLY Neo4j backend, based on the Brainstorm prototype.
The WoT data model extends the base Nostr relay functionality with trust metrics computation using graph algorithms (GrapeRank, Personalized PageRank) to enable:
The WoT model adds specialized nodes and relationships to track social graph structure and compute trust metrics.
Represents a Nostr user (identified by pubkey) with computed trust metrics.
Properties:
pubkey (string, unique) - Hex-encoded public keynpub (string) - Bech32-encoded npubTrust Metrics (Owner-Personalized):
hops (integer) - Distance from owner node via FOLLOWS relationshipspersonalizedPageRank (float) - PageRank score personalized to ownerinfluence (float) - GrapeRank influence scoreaverage (float) - GrapeRank average scoreinput (float) - GrapeRank input scoreconfidence (float) - GrapeRank confidence scoreSocial Graph Counts:
followingCount (integer) - Total number of users this user followsfollowedByCount (integer) - Total number of followersmutingCount (integer) - Total number of users this user mutesmutedByCount (integer) - Total number of users who mute this userreportingCount (integer) - Total number of reports filed by this userreportedByCount (integer) - Total number of reports filed against this userVerified Counts (GrapeRank-weighted):
verifiedFollowerCount (integer) - Count of followers with influence above thresholdverifiedMuterCount (integer) - Count of muters with influence above thresholdverifiedReporterCount (integer) - Count of reporters with influence above thresholdInput Scores (Sum of Influence):
followerInput (float) - Sum of influence scores of all followersmuterInput (float) - Sum of influence scores of all mutersreporterInput (float) - Sum of influence scores of all reportersNIP-56 Report Types:
For each report type (impersonator, spam, illegal, malware, nsfw, etc.), the following metrics are tracked:
{reportType}Count (integer) - Total count of this report type{reportType}VerifiedCount (integer) - Count from verified reporters{reportType}Input (float) - Sum of influence scores of reportersNote: NIP-56 metrics may be better modeled as separate nodes to avoid property explosion.
Indexes:
pubkeyhopspersonalizedPageRankinfluenceverifiedFollowerCountverifiedMuterCountverifiedReporterCountfollowerInputOrganizational node that groups all WoT metric cards for a single observee (user being scored). This design pattern keeps WoT metric cards partitioned from other NostrUser relationships.
Properties:
observee_pubkey (string, unique) - Pubkey of the user being scoredPurpose: Acts as an intermediary to minimize direct relationships on NostrUser nodes, which may have many other relationships in a full relay implementation.
Indexes:
observee_pubkeyStores personalized trust metrics for a specific (observer, observee) pair. Each card corresponds to a NIP-85 Trusted Assertion (kind 30382) event.
Properties:
customer_id (string) - Identifier for the customer/service instanceobserver_pubkey (string) - Pubkey of the observer (the customer)observee_pubkey (string) - Pubkey of the user being scoredTrust Metrics (Observer-Personalized): All the same metrics as NostrUser node, but personalized to the observer:
hops, personalizedPageRankinfluence, average, input, confidenceverifiedFollowerCount, verifiedMuterCount, verifiedReporterCountfollowerInput, muterInput, reporterInputIndexes:
(customer_id, observee_pubkey)(observer_pubkey, observee_pubkey)customer_idobserver_pubkeyobservee_pubkeyhopspersonalizedPageRankinfluenceverifiedFollowerCountverifiedMuterCountverifiedReporterCountfollowerInputLegacy node label that is redundant with SetOfNostrUserWotMetricsCards. Should be removed in new implementations.
The Neo4j backend uses a unified Tag-based model for e and p tags, enabling consistent tag querying while maintaining graph traversal capabilities.
E-tags (Event References):
(Event)-[:TAGGED_WITH]->(Tag {type: 'e', value: <event_id>})-[:REFERENCES]->(Event)
P-tags (Pubkey Mentions):
(Event)-[:TAGGED_WITH]->(Tag {type: 'p', value: <pubkey>})-[:REFERENCES]->(NostrUser)
This model provides:
#e and #p filters (same as other tags)Query Examples:
-- Find all events that reference a specific event
MATCH (e:Event)-[:TAGGED_WITH]->(t:Tag {type: 'e', value: $eventId})-[:REFERENCES]->(ref:Event)
RETURN e
-- Find all events that mention a specific pubkey
MATCH (e:Event)-[:TAGGED_WITH]->(t:Tag {type: 'p', value: $pubkey})-[:REFERENCES]->(u:NostrUser)
RETURN e
-- Count references to an event (thread replies)
MATCH (t:Tag {type: 'e', value: $eventId})<-[:TAGGED_WITH]-(e:Event)
RETURN count(e) AS replyCount
Represents a follow relationship between users (derived from kind 3 events).
Direction: (follower:NostrUser)-[:FOLLOWS]->(followed:NostrUser)
Properties: None (or optionally timestamp)
Source: Created/updated from kind 3 (contact list) events
Represents a mute relationship between users (derived from kind 10000 events).
Direction: (muter:NostrUser)-[:MUTES]->(muted:NostrUser)
Properties: None (or optionally timestamp)
Source: Created/updated from kind 10000 (mute list) events
Represents a report filed against a user (derived from kind 1984 events).
Direction: (reporter:NostrUser)-[:REPORTS]->(reported:NostrUser)
Deduplication: Only one REPORTS relationship exists per (reporter, reported, report_type) combination. Multiple reports of the same type from the same user to the same target update the existing relationship with the most recent event's data. This prevents double-counting in GrapeRank calculations while maintaining audit trails via ProcessedSocialEvent nodes.
Properties:
report_type (string) - NIP-56 report type (impersonation, spam, illegal, malware, nsfw, etc.)created_at (integer) - Timestamp of the most recent report eventcreated_by_event (string) - Event ID of the most recent reportrelay_received_at (integer) - When the relay first received any report of this typeSource: Created/updated from kind 1984 (reporting) events
Links a NostrUser to their SetOfNostrUserWotMetricsCards organizational node.
Direction: (user:NostrUser)-[:WOT_METRICS_CARDS]->(set:SetOfNostrUserWotMetricsCards)
Properties: None
Cardinality: One-to-one (each NostrUser has at most one SetOfNostrUserWotMetricsCards)
Links a SetOfNostrUserWotMetricsCards to individual NostrUserWotMetricsCard nodes for each observer.
Direction: (set:SetOfNostrUserWotMetricsCards)-[:SPECIFIC_INSTANCE]->(card:NostrUserWotMetricsCard)
Properties: None
Cardinality: One-to-many (one set has many cards, one per observer)
Note: May be renamed to WOT_METRICS_CARD for clarity.
The WoT model processes the following Nostr event kinds:
| Kind | Name | Purpose | Graph Action |
|---|---|---|---|
| 0 | Profile Metadata | User profile information | Update NostrUser properties (npub, name, etc.) |
| 3 | Contact List | Follow list | Create/update FOLLOWS relationships |
| 1984 | Reporting | Report users/content | Create/update REPORTS relationships (deduplicated by report_type) |
| 10000 | Mute List | Mute list | Create/update MUTES relationships |
| 30382 | Trusted Assertion (NIP-85) | Published trust metrics | Create/update NostrUserWotMetricsCard nodes |
Trust metrics are computed for users who meet any of these criteria:
This typically results in ~300k tracked users out of millions in the network.
GrapeRank is a trust scoring algorithm that computes:
Note: Implementation details for GrapeRank are not included in the specification.
Computes a personalized PageRank score for each user relative to an owner/observer, using the FOLLOWS graph as the link structure.
Note: Implementation details are not included in the specification.
Users with influence above a configurable threshold are considered "verified" for counting purposes. This provides a quality-weighted count of followers/muters/reporters.
Alternative to verified counts: sum the influence scores of all followers/muters/reporters to get a weighted measure of social signals.
Minimal WoT implementation suitable for resource-constrained deployments:
Hardware: Can run on smaller instances (e.g., 8 GB RAM, 2 vCPU)
Comprehensive implementation with additional features:
- IS_A_REACTION_TO (kind 7 reactions)
- IS_A_RESPONSE_TO (kind 1 replies)
- IS_A_REPOST_OF (kind 6, kind 16 reposts)
- Tag-based references (see "Tag-Based References" section above):
- Event-[:TAGGED_WITH]->Tag{type:'p'}-[:REFERENCES]->NostrUser (p-tag mentions)
- Event-[:TAGGED_WITH]->Tag{type:'e'}-[:REFERENCES]->Event (e-tag references)
Hardware: Requires larger instances (e.g., 32 GB RAM, 8 vCPU, 100+ GB SSD)
-- NostrUser node constraint and indexes
CREATE CONSTRAINT nostrUser_pubkey IF NOT EXISTS
FOR (n:NostrUser) REQUIRE n.pubkey IS UNIQUE;
CREATE INDEX nostrUser_hops IF NOT EXISTS
FOR (n:NostrUser) ON (n.hops);
CREATE INDEX nostrUser_personalizedPageRank IF NOT EXISTS
FOR (n:NostrUser) ON (n.personalizedPageRank);
CREATE INDEX nostrUser_influence IF NOT EXISTS
FOR (n:NostrUser) ON (n.influence);
CREATE INDEX nostrUser_verifiedFollowerCount IF NOT EXISTS
FOR (n:NostrUser) ON (n.verifiedFollowerCount);
CREATE INDEX nostrUser_verifiedMuterCount IF NOT EXISTS
FOR (n:NostrUser) ON (n.verifiedMuterCount);
CREATE INDEX nostrUser_verifiedReporterCount IF NOT EXISTS
FOR (n:NostrUser) ON (n.verifiedReporterCount);
CREATE INDEX nostrUser_followerInput IF NOT EXISTS
FOR (n:NostrUser) ON (n.followerInput);
-- SetOfNostrUserWotMetricsCards constraint
CREATE CONSTRAINT SetOfNostrUserWotMetricsCards_observee_pubkey IF NOT EXISTS
FOR (n:SetOfNostrUserWotMetricsCards) REQUIRE n.observee_pubkey IS UNIQUE;
-- NostrUserWotMetricsCard constraints and indexes
CREATE CONSTRAINT nostrUserWotMetricsCard_unique_combination_1 IF NOT EXISTS
FOR (n:NostrUserWotMetricsCard) REQUIRE (n.customer_id, n.observee_pubkey) IS UNIQUE;
CREATE CONSTRAINT nostrUserWotMetricsCard_unique_combination_2 IF NOT EXISTS
FOR (n:NostrUserWotMetricsCard) REQUIRE (n.observer_pubkey, n.observee_pubkey) IS UNIQUE;
CREATE INDEX nostrUserWotMetricsCard_customer_id IF NOT EXISTS
FOR (n:NostrUserWotMetricsCard) ON (n.customer_id);
CREATE INDEX nostrUserWotMetricsCard_observer_pubkey IF NOT EXISTS
FOR (n:NostrUserWotMetricsCard) ON (n.observer_pubkey);
CREATE INDEX nostrUserWotMetricsCard_observee_pubkey IF NOT EXISTS
FOR (n:NostrUserWotMetricsCard) ON (n.observee_pubkey);
CREATE INDEX nostrUserWotMetricsCard_hops IF NOT EXISTS
FOR (n:NostrUserWotMetricsCard) ON (n.hops);
CREATE INDEX nostrUserWotMetricsCard_personalizedPageRank IF NOT EXISTS
FOR (n:NostrUserWotMetricsCard) ON (n.personalizedPageRank);
CREATE INDEX nostrUserWotMetricsCard_influence IF NOT EXISTS
FOR (n:NostrUserWotMetricsCard) ON (n.influence);
CREATE INDEX nostrUserWotMetricsCard_verifiedFollowerCount IF NOT EXISTS
FOR (n:NostrUserWotMetricsCard) ON (n.verifiedFollowerCount);
CREATE INDEX nostrUserWotMetricsCard_verifiedMuterCount IF NOT EXISTS
FOR (n:NostrUserWotMetricsCard) ON (n.verifiedMuterCount);
CREATE INDEX nostrUserWotMetricsCard_verifiedReporterCount IF NOT EXISTS
FOR (n:NostrUserWotMetricsCard) ON (n.verifiedReporterCount);
CREATE INDEX nostrUserWotMetricsCard_followerInput IF NOT EXISTS
FOR (n:NostrUserWotMetricsCard) ON (n.followerInput);
MATCH path = (owner:NostrUser {pubkey: $ownerPubkey})-[:FOLLOWS*1..3]->(user:NostrUser)
WHERE user.hops <= 3
RETURN user.pubkey, user.hops, user.influence
ORDER BY user.influence DESC
LIMIT 100
MATCH (card:NostrUserWotMetricsCard {
observer_pubkey: $observerPubkey,
observee_pubkey: $observeePubkey
})
RETURN card.hops, card.influence, card.personalizedPageRank
MATCH (user:NostrUser)
WHERE user.influence > $threshold
AND user.verifiedFollowerCount > $minFollowers
RETURN user.pubkey, user.influence, user.verifiedFollowerCount
ORDER BY user.influence DESC
LIMIT 50
MATCH (reporter:NostrUser)-[r:REPORTS]->(reported:NostrUser)
WHERE reporter.influence > $threshold
RETURN reported.pubkey,
r.reportType,
COUNT(reporter) AS reportCount,
SUM(reporter.influence) AS totalInfluence
ORDER BY totalInfluence DESC
# Enable Neo4j backend
export ORLY_DB_TYPE=neo4j
export ORLY_NEO4J_URI=bolt://localhost:7687
export ORLY_NEO4J_USER=neo4j
export ORLY_NEO4J_PASSWORD=password
# Enable WoT processing
export ORLY_WOT_ENABLED=true
export ORLY_WOT_OWNER_PUBKEY=<hex-pubkey>
export ORLY_WOT_INFLUENCE_THRESHOLD=0.5
export ORLY_WOT_MAX_HOPS=3
# Enable multi-tenant support
export ORLY_WOT_MULTI_TENANT=true
Extend REQ filters with WoT parameters:
{
"kinds": [1],
"wot": {
"max_hops": 2,
"min_influence": 0.5,
"observer": "<pubkey>"
}
}