docs(quartz): name the link vocabulary in Nostr's own words

Each relation names what the target is to the event, in the NIP's word for
that slot where one exists:
- ROOT / PARENT / ROOT_AUTHOR / PARENT_AUTHOR (NIP-10, NIP-22);
- ZAP_SENDER / ZAP_RECIPIENT (NIP-57), SUBJECT (NIP-85);
- BADGE_DEFINITION / BADGE_AWARD (NIP-58), QUOTE (NIP-18).
Otherwise it is the past participle of the NIP's action (REACTED, REPORTED,
DELETED), and list entries are named as their list names them (FOLLOW,
BOOKMARK, MUTE). NIPs that reuse the root marker (NIP-28 channels, NIP-53 chats,
NIP-34 statuses) map to ROOT, as they tag it. PARENT is used over NIP-10's
reply marker so that PARENT_AUTHOR cannot be misread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pu8Fpp4KbXaiax8YTYxhJm
This commit is contained in:
Claude
2026-09-29 13:46:01 +00:00
parent 552f99fcc9
commit cc6b2a5d2d
+111 -82
View File
@@ -1,9 +1,8 @@
# A link vocabulary: what each kind's references MEAN
Status: **draft for review** (2026-09-29). Nothing is implemented yet. Decided in review: links
always start at an event (no user-to-user shortcuts), the author of acted-on content gets its OWN
relation, and the kind stays on the source event only. **Naming is still open** (the style below
is a placeholder).
always start at an event (no user-to-user shortcuts); the author of acted-on content gets its OWN
relation; the kind stays on the source event only; names follow Nostr's own words (rule 7).
## Why
@@ -53,24 +52,38 @@ Rules the vocabulary follows:
1. **Every link starts at the event that makes the statement.** The event is the provenance: its
author, its time, and the version that superseded it all hang off it. The one exception is
`OWNED_BY` (address → user), which no event states.
2. **One relation per action, across kinds.** `REPLIES_TO` is a kind 1 reply, a NIP-22 comment, a
git reply and a chat reply. The source event's `kind` says which; a query that cares filters
on it (`(c:Event {kind: 1111})-[:REPLIES_TO]->(x)`). Kinds are not repeated in the name.
3. **The author of acted-on content gets a relation of its own.** A reaction `REACTS_TO` the
note, and names the note's author through `REACTS_TO_AUTHOR`, not through a second
`REACTS_TO`. "Reactions to my notes" and "reactions to anything by me" stay one hop, and each
is its own constant-time count.
`AUTHOR` from an address to its pubkey, which no event states.
2. **One relation per role, across kinds.** `PARENT` is a kind 1 reply's parent, a NIP-22
comment's parent item, a git reply's and a chat reply's. The source event's `kind` says which;
a query that cares filters on it (`(c:Event {kind: 1111})-[:PARENT]->(x)`). Kinds are not
repeated in the name.
3. **The author of acted-on content gets a relation of its own.** A reaction points at the note
through `REACTED` and at the note's author through `REACTED_AUTHOR`. "Reactions to my notes"
and "reactions to anything by me" stay one hop, and each is its own constant-time count.
4. **Split a relation when queries separate its meanings on the same target type.** Counting a
relation per node is constant-time in Neo4j, but filtering on a property reads every edge.
So a distinction that is filtered all the time becomes two relations: `REPORTS_USER` (a
complaint about the person) is not `REPORTS_AUTHOR` (the author of reported content), and
`FOLLOWS` (the kind 3 social graph) is not `SUBSCRIBES_TO` (every other follow-like list).
So a distinction that is filtered all the time becomes two relations: `REPORTED_USER` (a
complaint about the person) is not `REPORTED_AUTHOR` (the author of reported content), and
`FOLLOW` (the kind 3 social graph) is not `SUBSCRIBED` (every other follow-like list).
5. **Nothing is invisible before it is classified.** A class that implements no `LinkProvider`
gets a default derived from its hint providers: every linked id becomes a `REFERENCES` link
gets a default derived from its hint providers: every linked id becomes a `REFERENCE` link
with `via` = the tag it came from. Classifying a kind later is an additive change.
6. **Values that qualify a link ride on it** (`props`): a report's type, an assertion's rank, a
zap request's amount. They are what a query filters on after choosing the relation.
7. **Names are Nostr's own words for the slot.** A relation names what the TARGET is to the
event: its `AUTHOR`, its `ROOT`, its `PARENT`, the `ZAP_RECIPIENT`. Where a NIP has a word for
the slot, that word is the name: NIP-10's markers (`root`), NIP-22's "root scope" and
"parent item" (`ROOT`, `PARENT`, `ROOT_AUTHOR`, `PARENT_AUTHOR`), NIP-57's "sender" and
"recipient", NIP-85's "subject", NIP-58's "badge definition" and "badge award", NIP-18's
"quote". Where a NIP uses a marker, the marker wins over a friendlier noun: a NIP-28 channel
message's channel is its `ROOT`, as NIP-28 tags it. Where a NIP has no word, or only a
generic one ("target"), the name is the past participle of the NIP's action: `REACTED`,
`REPOSTED`, `REPORTED`, `DELETED`, `TIMESTAMPED`. Lists name their entries the way the list
names them: a follow list holds `FOLLOW`s, a bookmark list `BOOKMARK`s. Casing is
UPPER_SNAKE, the Cypher convention, which also keeps relations apart from properties
(`r.report`).
- `PARENT`, not NIP-10's `reply` marker: `REPLY_AUTHOR` would read as the author of the
reply, and `(c)-[:REPLY]->(p)` as if `p` were the reply.
## The vocabulary
@@ -81,116 +94,116 @@ the relation comes from today; each row is a golden test when implemented.
| Relation | Targets | Meaning | Kinds |
|---|---|---|---|
| `AUTHORED_BY` | U | The event's signer | every kind |
| `VERSION_OF` | A | The addressable event's own address | 30000–39999 |
| `OWNED_BY` | U | address → its pubkey (not stated by an event) | every address |
| `AUTHOR` | U | The event's signer; from an address, its pubkey (the only link no event states) | every kind; every address |
| `ADDRESS` | A | The addressable event's own address (NIP-01) | 30000–39999 |
### Conversation
| Relation | Targets | Meaning | Kinds |
|---|---|---|---|
| `REPLIES_TO` | E, A | The direct parent | 1 (NIP-10, `replyingTo()`), 1111 (`e`/`a`/`p`), 1244, 1622, 2004, 30818, 14, 42, 1311, and 9 — whose reply parent is a **`q`** tag (NIP-C7), the case that shows why tag letters cannot be the schema |
| `THREAD_ROOT` | E, A | The thread's root | 1 (`root()`), 1111 (`E`/`A`), 1622, 42 |
| `REPLIES_TO_AUTHOR` | U | The direct parent's author | 1111 (`p`), 1244 |
| `THREAD_ROOT_AUTHOR` | U | The root's author | 1111 (`P`), 1244 |
| `MENTIONS` | E, A, U | Named in passing: a NIP-10 `mention` marker, a `p` that notifies, a `nostr:` URI in the text (`via: content`) | 1, 1111, 9, 24, 42, 1311, 1621, 1622, 9802, 30023, 30817, 30818, … |
| `QUOTES` | E, A | A `q` tag (except kind 9, where `q` is the reply parent) | 1, 42, 1111, 1311, 1621, 30023, … |
| `FORK_OF` | E | NIP-10 `fork` marker | 1 |
| `EDITS` | E | A later edit of that event | 1010 (TextNoteModification), 3302 |
| `POSTED_IN` | E, A | The container a message belongs to: a channel, live activity, community, repository | 42 (channel `root`), 1311 (`a`), 1617–1622 (repo `a`), posts tagging a 34550 |
| `SENT_TO` | U | A direct or gift-wrapped message's recipients | 4, 14, 15, 24, 1059, 21059 |
| `ROOT` | E, A | The root: NIP-10 `root` (`root()`), NIP-22 root scope (`E`/`A`), and every NIP that reuses the `root` marker — a NIP-28 message's channel (41, 42), a NIP-53 chat's activity (1311) and a presence's room (10312), a NIP-34 status's or PR update's patch/issue/PR (1630–1633, 1619 `E`) | 1, 1111, 1244, 1622, 41, 42, 1311, 10312, 1619, 1630–1633 |
| `PARENT` | E, A | The direct parent: NIP-10 `replyingTo()`, NIP-22 parent item (`e`/`a`), NIP-53's parent space (30313 → 30312), a NIP-34 status's accepted revision. Kind 9 (NIP-C7) puts its parent in a **`q`** tag — the case that shows why tag letters cannot be the schema | 1, 1111, 1244, 1622, 2004, 30818, 14, 42, 1311, 9, 30313, 1630–1633 |
| `ROOT_AUTHOR` | U | The root scope's author (NIP-22 `P`) | 1111, 1244 |
| `PARENT_AUTHOR` | U | The parent item's author (NIP-22 `p`) | 1111, 1244 |
| `MENTION` | E, A, U | Named in passing: a `p` that notifies, a NIP-10 `mention` marker, a `nostr:` URI in the text (NIP-27, `via: content`) | 1, 1111, 9, 24, 42, 1311, 1621, 1622, 9802, 30023, 30817, 30818, … |
| `QUOTE` | E, A | A NIP-18 `q` (except kind 9, where `q` is the parent) | 1, 42, 1111, 1311, 1621, 30023, … |
| `FORK` | E | The event a note forks (the `fork` marker) | 1 |
| `EDITED` | E | The event this one edits | 1010 (TextNoteModification), 3302 |
| `RECIPIENT` | U | A direct or gift-wrapped message's recipients | 4, 14, 15, 24, 1059, 21059 |
| `COMMUNITY` | A | A NIP-72 community a post is submitted to (and an approval's community) | posts tagging a 34550, 4550 |
| `REPOSITORY` | A | A NIP-34 patch's, PR's or issue's repository | 1617, 1618, 1621 |
### Reactions, reposts, zaps
| Relation | Targets | Meaning | Kinds |
|---|---|---|---|
| `REACTS_TO` | E, A, T | The reacted-to content (the last `e`/`a`, `originalPost()`); kind 17 reacts to a URL / external id (T) | 7, 17 |
| `REACTS_TO_AUTHOR` | U | Its author (`originalAuthor()`) | 7 |
| `REPOSTS` | E, A | The reposted content (`boostedEventId()` / `boostedAddress()`) | 6, 16 |
| `REPOSTS_AUTHOR` | U | Its author | 6, 16 |
| `ZAPS` | E, A | The zapped content. Props: `msats` | 9734, 9735, 9733, 9321, 8333, 9736, 9737 |
| `ZAP_RECIPIENT` | U | Who is paid (NIP-57 `p`). Props: `msats` | same |
| `ZAP_SENDER` | U | Who paid (NIP-57 `P`, the embedded request's author) | 9735 |
| `HIGHLIGHTS` | E, A | The highlighted source | 9802 |
| `HIGHLIGHTS_AUTHOR` | U | Its author | 9802 |
| `RATES` | E, A, U | The rated entity | 34259 |
| `REACTED` | E, A, T | The reacted-to content (the last `e`/`a`, `originalPost()`); kind 17 reacts to a URL / external id (T) | 7, 17 |
| `REACTED_AUTHOR` | U | Its author (`originalAuthor()`) | 7 |
| `REPOSTED` | E, A | The reposted content (`boostedEventId()` / `boostedAddress()`) | 6, 16 |
| `REPOSTED_AUTHOR` | U | Its author | 6, 16 |
| `ZAPPED` | E, A | The zapped content. Props: `msats` | 9734, 9735, 9733, 9321, 8333, 9736, 9737 |
| `ZAP_RECIPIENT` | U | Who is paid (NIP-57 `p`, the "recipient"). Props: `msats` | same |
| `ZAP_SENDER` | U | Who paid (NIP-57 `P`, the "sender": the embedded request's author) | 9735 |
| `HIGHLIGHTED` | E, A | The highlighted source | 9802 |
| `HIGHLIGHTED_AUTHOR` | U | Its author | 9802 |
| `RATED` | E, A, U | The rated entity | 34259 |
### Moderation
| Relation | Targets | Meaning | Kinds |
|---|---|---|---|
| `DELETES` | E, A | NIP-09 targets | 5 |
| `REPORTS_USER` | U | A report about the PERSON: it names no event, address or blob | 1984 |
| `REPORTS` | E, A, T | Reported content (T: a blob hash) | 1984 |
| `REPORTS_AUTHOR` | U | The author of reported content | 1984 |
| `LABELS` | E, A, U, T | NIP-32 targets. Props: `labels` (the `l` values, with namespace) | 1985 |
| `MUTES` | U, E, T | A user's own mutes: people, threads, words/hashtags | 10000, 30007 |
| `HIDES` | E, U | A channel moderator hides a message (43) or a user (44) in the channel — moderation, not a personal mute | 43, 44 |
| `APPROVES` | E, A | A community moderator approves a post | 4550 |
| `DELETED` | E, A | NIP-09 deletion request targets | 5 |
| `REPORTED_USER` | U | A report about the PERSON: it names no event, address or blob | 1984 |
| `REPORTED` | E, A, T | Reported content (T: a blob hash) | 1984 |
| `REPORTED_AUTHOR` | U | The author of reported content | 1984 |
| `LABELED` | E, A, U, T | NIP-32 label targets. Props: `labels` (the `l` values, with namespace) | 1985 |
| `MUTE` | U, E, T | A mute list's entries: people, threads, words/hashtags | 10000, 30007 |
| `HIDDEN` | E | A NIP-28 "hide message" | 43 |
| `CHANNEL_MUTED` | U | A NIP-28 "mute user": channel moderation, not a personal mute | 44 |
| `APPROVED` | E, A | A NIP-72 approval's post | 4550 |
| `MODERATOR` | U | A community's moderators | 34550 |
Report props (all three report relations): `report` (the category, Quartz's `ReportType` code),
`report_raw` (the type as written, lowercased). Splitting the relations replaces the `scope`
property of the current graph schema: "user-wide reports of X" is
`COUNT { (x)<-[:REPORTS_USER]-() }`, constant-time.
`COUNT { (x)<-[:REPORTED_USER]-() }`, constant-time.
### Social graph and lists
| Relation | Targets | Meaning | Kinds |
|---|---|---|---|
| `FOLLOWS` | U | The kind 3 follow list — the social graph | 3 |
| `SUBSCRIBES_TO` | U, E, A, T | Every other "follow this" list: media follows, communities, public chats, interests (hashtags and interest sets) | 10020, 10004, 10005, 10015 |
| `LISTS` | U, E, A | Membership in a named set or directory: follow sets, starter packs, author lists, trusted lists, calendars, publications, emoji sets | 30000, 39089, 39092, 10017, 10101, 10064, 30392–30395, 31924, 30040, 30045, 10030 |
| `RECOMMENDS` | A | An app-handler recommendation | 31989 |
| `BOOKMARKS` | E, A | Private-ish saves | 10003, 30001, 30003 |
| `CURATES` | E, A | Published curation sets | 30004, 30005, 30006, 30063, 30267, 37517 |
| `PINS` | E | Pinned to a profile or a live stream | 10001, 30311 / 30313 (`pinned`) |
| `FOLLOW` | U | A kind 3 follow list's entries — the social graph | 3 |
| `SUBSCRIBED` | U, E, A, T | Every other "follow this" list: media follows, communities, public chats, interests (hashtags and interest sets) | 10020, 10004, 10005, 10015 |
| `MEMBER` | U, E, A | Membership in a named set or directory: follow sets, starter packs, author lists, trusted lists, calendars, publications, emoji sets | 30000, 39089, 39092, 10017, 10101, 10064, 30392–30395, 31924, 30040, 30045, 10030 |
| `RECOMMENDED` | A | A NIP-89 recommendation's app handler | 31989 |
| `BOOKMARK` | E, A | Bookmark lists' and sets' entries | 10003, 30001, 30003 |
| `CURATED` | E, A | Published curation sets' entries | 30004, 30005, 30006, 30063, 30267, 37517 |
| `PIN` | E | Pinned to a profile or a live stream | 10001, 30311 / 30313 (`pinned`) |
### Badges
### Badges (NIP-58)
| Relation | Targets | Meaning | Kinds |
|---|---|---|---|
| `AWARDS` | U | A badge award's recipients | 8 |
| `BADGE` | A | The badge definition an award or a profile refers to | 8, 30008, 10008 |
| `ACCEPTS` | E | A profile accepting an award | 30008, 10008 |
| `AWARDED` | U | A badge award's recipients ("each pubkey the issuer wishes to award") | 8 |
| `BADGE_DEFINITION` | A | The badge definition an award or a profile refers to | 8, 30008, 10008 |
| `BADGE_AWARD` | E | The badge award a profile displays | 30008, 10008 |
### Trust (NIP-85)
| Relation | Targets | Meaning | Kinds |
|---|---|---|---|
| `ASSERTS` | U, E, A | The assertion's subject (`d`). Props: `rank`, `followers`, … | 30382, 30383, 30384 |
| `TRUSTS_PROVIDER` | U | A 10040's service for one assertion. Props: `service` (`30382:rank`) — one link per service entry | 10040 |
| `SUBJECT` | U, E, A | The assertion's subject (`d`). Props: `rank`, `followers`, … | 30382, 30383, 30384 |
| `SERVICE_PROVIDER` | U | A 10040's provider for one assertion. Props: `service` (`30382:rank`) — one link per entry | 10040 |
### Events, calendars, live activities, markets
| Relation | Targets | Meaning | Kinds |
|---|---|---|---|
| `PARTICIPANT` | U | Listed participants / speakers / hosts | 30311, 30312, 30313, 31922, 31923 |
| `RSVPS` | A, E | A calendar RSVP's event | 31925 |
| `PRESENT_IN` | A | Presence in a meeting room | 10312 |
| `RAIDS` | A | A live-activity raid target | 1312 |
| `CLIPS` | A, U | A clip of a stream | 1313 |
| `VOTES_IN` | E | A poll response's poll | 1018 |
| `BIDS_ON` / `CONFIRMS_BID` | E | Marketplace bids | 1021 / 1022 |
| `TIMESTAMPS` | E | An OpenTimestamps proof's target | 1040 |
| `STATUS_OF` | E | A NIP-34 status for a patch / issue | 1630–1633 |
| `UPDATES` | E | A later statement about that event: a PR update's pull request, a channel's new metadata | 1619, 41 |
| `REDIRECTS_TO` | A | A wiki redirect | 30819 |
| `CALENDAR_EVENT` | A, E | A calendar RSVP's calendar event | 31925 |
| `RAIDED` | A | A live-activity raid's target | 1312 |
| `CLIPPED` | A | A clip's stream | 1313 |
| `CLIPPED_AUTHOR` | U | The clipped stream's host | 1313 |
| `POLL` | E | A poll response's poll | 1018 |
| `AUCTION` | E | A bid's (and a bid confirmation's) auction | 1021, 1022 |
| `BID` | E | The bid a confirmation confirms | 1022 |
| `TIMESTAMPED` | E | An OpenTimestamps proof's target (NIP-03 says "target", too generic to name a relation) | 1040 |
| `REDIRECT` | A | A wiki redirect's destination | 30819 |
### Topics and plain tags
| Relation | Targets | Meaning | Kinds |
|---|---|---|---|
| `TOPIC` | T | A hashtag (`t`) | any |
| `TAGGED` | T | Any other allowlisted value tag: `i` (external id), `k`, `l`/`L`, `r` (url), `g` (geohash). The target's name says which | any |
| `HASHTAG` | T | A `t` tag | any |
| `TAG` | T | Any other allowlisted value tag: `i` (external id), `k`, `l`/`L`, `r` (url), `g` (geohash). The target's name says which | any |
### Fallback
| Relation | Targets | Meaning |
|---|---|---|
| `REFERENCES` | E, A, U | A link a provider names (or a value shaped like an id) that no relation above claims. Props: `tag` |
| `REFERENCE` | E, A, U | A link a provider names (or a value shaped like an id) that no relation above claims. Props: `tag` |
Until classified, these stay `REFERENCES`:
Until classified, these stay `REFERENCE`:
- NIP-90 DVM requests, results and feedback (5000–7000);
- NIP-29 group events;
- experimental kinds (workouts, geocaching, roadstr, attestations, zap polls);
@@ -202,25 +215,41 @@ Until classified, these stay `REFERENCES`:
Each is a small, additive classification when someone needs it.
## Reading it back
```cypher
// a whole reply tree
MATCH (:Event {id: $root})<-[:PARENT*]-(r) RETURN r
// reactions to my posts by people I follow
MATCH (me:User {pubkey: $me})<-[:AUTHOR]-(:Event {kind: 3})-[:FOLLOW]->(f),
(f)<-[:AUTHOR]-(r)-[:REACTED_AUTHOR]->(me)
RETURN r
// user-wide reports against X, by category
MATCH (:User {pubkey: $x})<-[r:REPORTED_USER]-() RETURN r.report, count(*)
// who zapped whom, from one sender
MATCH (:User {pubkey: $x})<-[:ZAP_SENDER]-(z)-[:ZAP_RECIPIENT]->(u) RETURN u, sum(z.msats)
```
## Open questions for review
Decided:
- **No user-to-user shortcuts.** `FOLLOWS` runs from the kind 3 event, like every other list. There
- **No user-to-user shortcuts.** `FOLLOW` runs from the kind 3 event, like every other list. There
are more than twenty people lists, and a shortcut for one invites one for each.
- **The author of acted-on content has its own relation** (rule 3).
- **`kind` stays on the source event only.** A property on billions of links would cost tens of GB,
and the source node is one hop away. A relation whose counts are needed per kind is split
instead (as `FOLLOWS` is).
instead (as `FOLLOW` is).
- **Names follow Nostr's words** (rule 7), with `PARENT` for the direct parent.
Open:
4. **Naming style.** UPPER_SNAKE, verbs in the present tense, as Neo4j convention has it.
Alternatives welcome on any row: `THREAD_ROOT` vs `IN_THREAD`, `LISTS` vs `LISTS_MEMBER`,
`AUTHORED_BY` vs `BY`.
5. **Where it lives.** `nip01Core/links/` (the interface, the value classes, the relation
1. **Where it lives.** `nip01Core/links/` (the interface, the value classes, the relation
constants) plus one `links()` per class, beside its tags. The default (rule 5) sits on `Event`
and reads the hint providers.
6. **Vocabulary stability.** Adding a relation or classifying a kind is additive. Renaming or
2. **Vocabulary stability.** Adding a relation or classifying a kind is additive. Renaming or
re-splitting one breaks graph queries, so this review is the cheap moment.
## What changes downstream
@@ -245,7 +274,7 @@ New: two classes claim kind **1010**, `experimental/edits/TextNoteModificationEv
## Plan
1. This review: the vocabulary, the model, the open questions.
1. This review: the vocabulary, the model, the open questions. Done except the two open points.
2. Quartz: `nip01Core/links/` and the default from the hint providers; then `links()` for the
kinds the graph already interprets (NIP-10, 18, 22, 25, 56, 57, 85, 51, 58, 72, 09), each with
a golden test. The upstream fixes above land with them.