From 16b540224c6233a0818c086e1f588304cd9d2b8c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 14:14:42 +0000 Subject: [PATCH] docs(quartz): classify every Quartz event class's links; drop the fallback Every one of the 410 classes EventFactory types is now read against its tags, its parsers and its NIP. 340 carry references and 70 carry none. The results are in the new appendix, 2026-09-29-graph-link-vocabulary-appendix.md: one row per class with its links, the Quartz accessor to build them from, and notes. It also lists the 115 relations the tables did not have yet. Synonyms coined by different groups are unified: ADDED_USER / REMOVED_USER, REQUEST / REQUEST_AUTHOR, ZAP_SPLIT. The plan changes to match: - Rule 5 is now "no fallback". Every class implements links() or is declared link-free, and a coverage test holds that. Shape-guessing is unsafe: a chess board hash looks like an event id, and `t` / `r` mean other things on some kinds. - The tags every kind may carry (client, zap splits, emoji sets) move to Event. - Corrections to the tables from the review, and the open decisions it raised (groups, URL targets, derived links, content JSON). - The Quartz bugs found, including a privacy bug in GeohashListEvent and the real kind collision at 20001. The earlier claim of a 1010 collision was a prefix-match mistake and is corrected. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pu8Fpp4KbXaiax8YTYxhJm --- ...26-09-29-graph-link-vocabulary-appendix.md | 945 ++++++++++++++++++ .../plans/2026-09-29-graph-link-vocabulary.md | 135 ++- 2 files changed, 1045 insertions(+), 35 deletions(-) create mode 100644 quartz/plans/2026-09-29-graph-link-vocabulary-appendix.md diff --git a/quartz/plans/2026-09-29-graph-link-vocabulary-appendix.md b/quartz/plans/2026-09-29-graph-link-vocabulary-appendix.md new file mode 100644 index 0000000000..18b0e6fa5f --- /dev/null +++ b/quartz/plans/2026-09-29-graph-link-vocabulary-appendix.md @@ -0,0 +1,945 @@ +# Appendix: every Quartz event class, and what its references mean + +Companion to [`2026-09-29-graph-link-vocabulary.md`](2026-09-29-graph-link-vocabulary.md). +Generated 2026-09-29 from `utils/EventFactory.kt`: **410 classes**, each read against its tags, +its tag parsers and its NIP (or, for Quartz-only families, its package docs). **340** carry +references; **70** carry none (they get only `AUTHOR`, and `ADDRESS` when addressable). + +How to read a row: +- **Links**: `tag[marker/slot] -> RELATION (targets)`, targets **E** event, **A** address, + **U** user, **T** tag value. `AUTHOR` and `ADDRESS` apply to every class and are not + repeated. +- **Built from**: the Quartz accessor or tag parser the class's `links()` would call, or + "new parser needed". +- **Notes**: the NIP or spec, `UNCERTAIN:` where the spec is silent or unmerged, `DRAFT FIX:` + where the vocabulary tables need correcting, and Quartz bugs found while reading. + +Relation names are normalized to one per role (rule 2): `ADDED`→`ADDED_USER`, +`REMOVED`→`REMOVED_USER`, `JOB_REQUEST`→`REQUEST`, `CUSTOMER`→`REQUEST_AUTHOR` (NIP-90's word +is "customer"), `BENEFICIARY`→`ZAP_SPLIT`. + +## Relations this review adds + +Not in the vocabulary tables yet; each needs the maintainer's review like the tables did. +**Any kind** marks a tag that can appear on every event, better emitted once by the default on +`Event` than per class. + +| Relation | Kinds | Justification (from the reviewing pass) | +|---|---|---| +| `ABOUT` | 23903, 30392, 30393, 30394, 30395 | the event a wake-up is about (Quartz builder about(); also the trusted lists' discovery slot) | +| `ABOUT_AUTHOR` | 23903 | its author (rule 3; KDoc: 'p-tags identify the AUTHORS of the referenced events', not recipients) | +| `ACCEPTED` | 30065 | the challenge this event accepts (past participle of the action). OPPONENT: see 30 | +| `ACTOR` | 8002, 8003, 40099, 44100, 44101, 48001 | – | +| `ADDED_USER` | 8000, 9000, 9030, 40099, 41011, 44100 | past participle of NIP-43 'Add User' / NIP-29 put-user; the member an add command/notification adds. Props: role. Should be shared with NIP-43 8000 and NIP-29 9000 | +| `ADMIN` | 39001 | a NIP-29 group's admins (kind 39001 'group admins'; props: roles). It is kept apart from NIP-72 MODERATOR because roles are relay-defined | +| `AGENT` | 24200, 30177, 43001, 44200 | Buzz's `agent` tag word; the AI agent a frame/metric/job/managed-agent record is about | +| `ALLOWED` | 30175, 30177, 34551 | entries of NIP-AP's respond_to_allowlist (who the agent answers), named as the list names them | +| `APP` | 5129, 15128, 15129, 35128, 35129 | NIP-5A `app` tag, 'an addressable event reference to an app descriptor' (NIP-89 31990 / 32267) | +| `APPLICATION` | 30063 | NIP-51's own example names the a the 'Reference to parent software application' (kind 32267): the release belongs to it, it is not a curated item | +| `APPLIED` | 1631 | the patch(es) a 1631 status applied or merged (NIP-34 'applied-or-merged-patch-event-id'; past participle of the status' own name). A q here is not a NIP-18 quote. REPOSITORY_OWNER: see 1617 | +| `APPROVED_AUTHOR` | 4550 | rule 3 - NIP-72 requires 'the p tag of the author of the post (for approval notifications)' | +| `APPROVER` | 46010 | Buzz's word; the person whose approval a paused workflow waits for | +| `ARCHIVED` | 8002, 9035, 13535 | past participle of NIP-IA's action (archive identity); also the entries of the 13535 archived list \| ACTOR: who performed/consented to the action a relay-signed record reports (NIP-IA consent tag's 'actor'); props path=self/owner/admin \| REQUEST: the originating request this relay-signed delta answers (Buzz 'originating request'; also fits NIP-90 results) \| REPLACED_BY: NIP-IA's own word for the successor identity (rotation pointer) | +| `ASSERTION` | 31871, 31872 | the event an attestation or attestation request is about (Quartz assertionEventId/assertionAddrId; UNCERTAIN it is the spec's word, fallback ATTESTED) | +| `ATTESTOR` | 31872 | an attestor asked to attest (the spec family's own word; builder attestorPubKeys). ASSERTION: see 31871 | +| `AUCTION_AUTHOR` | 1021 | the auction's merchant (rule 3, the author of acted-on content, as REACTED_AUTHOR). Quartz writes it via notifyAuthor() | +| `AUDITED` | 48001 | past participle of the audit action; the object an entry records an action on. Props: action | +| `AUTHORED` | 10064 | NIP-F4/NIP-51 'podcasts the user authors' - the counter-claim that verifies a 10154 PODCAST_AUTHOR; past participle of the NIP's action | +| `BADGE_SET` | 10008 | NIP-58 '(Profile badges) may also contain a tags referencing "Badge Set" events' (30008) - the NIP's own noun; not a badge definition | +| `BANNED` | 9040 | past participle of Buzz's ban action. Props: expiration, reason. (Unban 9041, not registered, would be UNBANNED) | +| `BASE_VERSION` | 818 | 'version of the article on which this modification is based' (no marker) | +| `BID_AUTHOR` | 1022 | the bidder (rule 3). Quartz writes it via notifyBidder() | +| `CALENDAR` | 31922, 31923 | NIP-52 'a (repeated) reference tag to kind 31924 calendar event requesting to be included in Calendar' - the NIP's noun for the target | +| `CALENDAR_EVENT_AUTHOR` | 31925 | rule 3 author of the acted-on calendar event; NIP-52 'p (optional) pubkey of the author of the calendar event being responded to' | +| `CHILD` | 9002, 39000 | see 9002 | +| `CLIENT` | **any kind** — seen on 31990 | NIP-89 client tag ('identifying the client that published the note' by its 31990 address) - cross-cutting, any event may carry it | +| `COLLABORATED` | 34238 | the NIP-71 video the signer accepts (or declines) a collaborator credit on; props role, status (accepted\|declined; absent = accepted). COLLABORATED_AUTHOR: that video's author (rule 3) | +| `COLLABORATED_AUTHOR` | 34238 | – | +| `CONCEPT_GRAPH` | 39998 | – | +| `CONFIRMED` | 1316 | the kind-1315 report a Roadstr confirmation confirms or denies (spec: 'report being confirmed or denied'; the kind is named Road Event Confirmation). Props: status (still_there \| no_longer_there) | +| `COPIED` | 15128, 15129, 35128, 35129 | NIP-5A 'a copied site MUST include exactly one lowercase a tag referencing the immediate parent nsite from which it was copied' (past participle of the NIP's action) | +| `CREATED` | 7376 | NIP-60 marker 'created' - the token event this spend created | +| `CREDITED` | 21, 22, 34235, 34236 | divine.video credit markers on p/a/e (inspired-by, audio, collaborator...) that are neither a NIP-71 participant nor a mention; one relation + props.credit instead of one relation per free-text label | +| `CURRENT_SCENE` | 30298 | the scene a reader is at (Quartz currentScene()) | +| `DEFER` | 30818 | the NIP-54 `defer` marker (rule 7, marker wins) - 'considers someone else's entry as a better version of itself'; a WoT-weight transfer, neither a fork nor a mention | +| `DELETED_AUTHOR` | 5 | rule 3 author of the acted-on content; Quartz's builders write a `p` per deleted event's author. For a valid request it always equals AUTHOR, so it may be dropped if the maintainer prefers - but the tag exists and points at a pubkey | +| `DENIED` | 34551 | – | +| `DESTINATION` | 818 | NIP-54 addresses the request to 'destination-pubkey' and its a is '30818::' (the NIP's only other word is the generic 'target') | +| `DESTINATION_AUTHOR` | 818 | rule 3 author of the acted-on article (NIP-54 'destination pubkey') | +| `DESTROYED` | 7376 | NIP-60 marker 'destroyed' - the token event it consumed | +| `EDITED_AUTHOR` | 1010 | the edited note's author (rule 3). create(notify=) writes it only when editing someone else's note (EditPostViewModel), so it is the author of acted-on content, not a passing mention | +| `ELEMENT_OF` | 39999 | – | +| `EMOJI_SET` | **any kind** — seen on 0, 1, 7, 17, 1111, 10030, 30023, 30030 … (9 kinds) | NIP-30's own name for the optional 4th emoji-tag slot ('the kind 30030 emoji set the emoji belongs to'); an address pointer, so it needs a relation; cross-cutting on every kind NIP-30 allows emoji tags on (0, 1, 1111, 7, 30315) plus 10030/30030 | +| `EXERCISE` | 1301 | a POWR/NIP-101e set's kind-33401 exercise template (the tag's own name; props weight/reps/rpe/set_type) | +| `FAVORITE` | 10012, 10021, 10054, 10090 | NIP-51 names these lists by 'favorite' (10012 'user favorite browsable relays (and relay sets)', 10021 'Favorite follow sets', 10054 'Favorite podcasts'); alternative SUBSCRIBED | +| `FILE_DATA` | 1065 | the kind-1064 storage event holding the bytes this header describes (NIP-95 draft); no existing relation means 'the payload of this metadata' | +| `FINDER` | 7517 | NIP-CC's word for the person the verification attests ('the finder's pubkey') | +| `FOR_USER` | 5300, 5301 | DVM spec kinds/5300 'pubkey of the user to generate recommendations for' (Quartz writes it as ["param","user",hex]); USER alone would collide with the User node label | +| `FOUND` | 7516 | NIP-CC kind 7516 is the 'Found Log' that 'record[s] successful visits'; past participle of the NIP's action | +| `FUNDED` | 9041 | NIP-75 'The goal MAY include an r or a tag linking to a URL or addressable event' - use case 'adding funding goals to events'; past participle of the goal's action | +| `GOAL` | 30311 | the NIP-75 zap goal (kind 9041) a stream raises toward (the tag's own name, 'goal') | +| `GROUP` | 444, 445, 9000, 9001, 9002, 9005, 9007, 9008 … (59 kinds) | NIP-29 `h` group id (Buzz channel UUID) the event is scoped to, target Tag("h", id); NIP-29's word for the slot; `h` is the one reference every channel-scoped Buzz kind carries and it is not E/A/U, so it needs a T relation (propose adding `h` to the allowlisted value tags) | +| `INHERIT_FROM` | 39998, 39999 | the node a b tag claims to inherit from / correspond to (Tapestry draft 'Inherit-From'); props type (pointer\|inherit\|inherit-items). CONCEPT_GRAPH: the concept's Concept Graph core node (tag name) | +| `INPUT` | 5000, 5001, 5002, 5050, 5100, 5200, 5201, 5202 … (38 kinds) | NIP-90 'i' is 'Input data for the job' (props input_type, marker) | +| `INPUT_JOB` | 5000, 5001, 5002, 5050, 5100, 5200, 5201, 5202 … (38 kinds) | NIP-90 input-type 'job' = 'the output of a previous job with the specified event ID' (job chaining), target is that job request | +| `ITEM` | 9999, 39999 | – | +| `KEY_PACKAGE` | 444 | the kind 30443 KeyPackage event this Welcome consumed (MIP-02 names the slot 'KeyPackage'). GROUP: see 9007 | +| `KICKED` | 4312 | the participant a room host ejects (the nostrnests / EGG-07 verb 'kick', as past participle); CHANNEL_MUTED reused for the force-mute verb (room-scoped moderation, not a personal mute) | +| `LINKED` | 30315 | NIP-38 'The status MAY include an r, p, e or a tag linking to a URL, profile, note, or addressable event' - past participle of the NIP's verb; deliberate (the status is about it), so not MENTION | +| `MAINTAINER` | 30617 | the repository's other recognized maintainers (NIP-34 'maintainers' tag; a list named as the list names it) | +| `MERCHANT` | 30019 | the merchants a NIP-15 marketplace groups ('merchants': array of pubkeys). Lists name their entries as the list does (rule 7) | +| `NOTIFICATION_SERVER` | 447, 448, 449 | the push notification server a token record targets (features/push-notifications.md 'notification server'; record key member_id, leaf, platform, server_pubkey) | +| `OBSERVER` | 30392, 30393, 30394, 30395 | the point of view the list was computed under (tag name, NIP-85 vocabulary) | +| `OPEN_TIMESTAMP` | 31 | the kind-1040 NIP-03 proof attesting when the cited page was seen; named after the tag (the spec's word for the slot), not TIMESTAMPED, which is the 1040's own link to its target | +| `OPPONENT` | 30, 30064, 30065, 30066, 30067, 30068 | the other player (Quartz OpponentTag/opponentPubkey(); Jester FLOW 'opponent'), shared with the live chess kinds 30064-30068 | +| `OPTION` | 30296, 30297 | a scene an interactive story branches to (the tag's own name; props: the option text) | +| `ORIGIN` | 5129, 15128, 15129, 35128, 35129 | the uppercase A, 'the origin nsite of the copy lineage' (the NIP's word) | +| `OWNER` | 9035, 9036, 30174, 44200 | NIP-OA's own word; the owner key attesting the event's (agent) author via the `auth` tag, or the owner an agent reports to (NIP-AM/NIP-AE `p`). Props: conditions (attestation only) | +| `PALETTE` | 3330, 11333, 33331 | DECK-0003 §1.3b names the payload field `palette`: an nevent/naddr to a palette published as its own event; a content-borne event/address reference that is not a passing mention | +| `PARENT_LIST` | 9999, 39999 | the list header an item is filed under (spec: 'a pointer to the parent list (the list header)'); T when the z is the bare name of an undeclared list. ITEM: the thing declared as an item on that list (spec: 'declaring a pubkey, event id, string, or naddr as an item on a list') | +| `PERSONA` | 30177 | NIP-AP's word; the 30175 persona a managed agent is defined by (Address 30175:author:persona_id) | +| `PODCAST_AUTHOR` | 10154 | NIP-F4 '["p", , ]'; AUTHOR is taken by the signer (here the podcast key itself), so the NIP's own 'podcast author' is the name | +| `POLL_AUTHOR` | 1018 | rule 3 - the author of the acted-on poll; Quartz writes a p for the poll author (notifyAuthor) that NIP-88 does not define | +| `PUBLICATION` | 30041 | the kind-30040 index a section belongs to (Quartz publicationAddress(); the inverse of the index's MEMBER, stated by the section) | +| `RATED_AUTHOR` | 34259 | the rated entity's author (rule 3; the p is 'the rated author', not the rated user, which comes from d when mark=profile) | +| `REDEEMED` | 7376 | NIP-60/61 marker 'redeemed' - the nutzap (9321) this history entry claimed | +| `REDEEMED_AUTHOR` | 7376 | rule 3 - NIP-61 'pubkey of the author of the 9321 event (nutzap sender)' | +| `RELEASE` | 32267 | an application's release (kind 30063 NIP-82 / NIP-51 release artifact set) that the app event points to | +| `REMINDED` | 40007 | past participle of the reminder action; the message a reminder is about (would also serve NIP-ER 30300 targets) | +| `REMOVED_USER` | 8001, 9001, 9031, 40099, 44101 | past participle of NIP-43 'Remove User' / NIP-29 remove-user; should be shared with NIP-43 8001 and NIP-29 9001 | +| `REPLACED_BY` | 8002, 9035 | – | +| `REPOSITORY_OWNER` | 1617, 1618, 1619, 1621, 1630, 1631, 1632, 1633 | NIP-34's 'repository-owner' p on patches, PRs, issues and statuses (rule 3 author-of-acted-on-content, named by the NIP's word). It is the one-hop 'patches to my repos'. | +| `REQUEST` | 819, 6000, 6001, 6002, 6050, 6100, 6200, 6201 … (33 kinds) | the request event a response answers (CLINK: 'a response is distinguished by an e tag referencing the request'); same role as NIP-47/NIP-90 responses and an attestation's request, none classified yet | +| `REQUEST_AUTHOR` | 819, 6000, 6001, 6002, 6050, 6100, 6200, 6201 … (25 kinds) | the requester, i.e. the author of the REQUEST target (rule 3; also fits NIP-90 result/feedback `p`) | +| `RESOLVED` | 9044 | past participle of Buzz's resolve-report action; the kind 1984 report being closed. Props: status (resolved/dismissed), action (delete/kick/ban/timeout/dismiss/escalate), reason | +| `RESULT` | 819, 6300, 6301, 6302, 6303, 6900, 6905, 6970 | NIP-90 'Job result ... providing the output'; the entities a job returns in content (via content) | +| `REVISED` | 1618 | the root patch this PR is a revision of (NIP-34: 'indicate PR is a revision of an existing patch, which should be closed'; past participle of the action) | +| `ROLE_CHANGED` | 9032 | past participle of Buzz's change-role action; the member whose role changes. Props: role. Distinct from ADDED_USER because the target is already a member | +| `SCHEDULED` | 5905 | DVM spec kinds/5905 'Schedule events to be published in the future'; past participle of the action for the signed event the DVM will publish | +| `SEARCH_AUTHOR` | 5302 | DVM spec kinds/5302 param 'users' = 'pubkeys of users to filter notes from' (the authors the search is restricted to) | +| `SITE_MANIFEST` | 31990 | NIP-89 'App descriptor events SHOULD tag or otherwise reference related site manifest events' (latest/next nsite manifests) | +| `SNAPSHOTTED` | 5129 | NIP-5A snapshot (5128) 'MUST include exactly one a tag referencing the source root site or named site' - past participle of the NIP's action; ORIGIN / APP: see 15128 | +| `SOURCE` | 818, 1163, 30040 | the event a reproduced piece of content was taken from (here the post the gallery picture came from; also used for NKBIP-01 derivative works on 30040). Quartz calls the slot fromEvent | +| `SOURCE_TAG` | 30392, 30393, 30394, 30395 | the tag definition the membership was computed from (tag name) | +| `STALL` | 30018, 30020 | the NIP-15 stall a product or auction belongs to ('stall_id: id of the stall to which this product belong to'). The address is derived from the author plus the content stall_id | +| `SUBSET_OF` | 39999 | the superset a set claims to be a subset of ('s'). Named with the draft's words; the draft derives the reversed edges HAS_ELEMENT / IS_A_SUPERSET_OF | +| `TAGGED` | 20 | NIP-68 names its p tags 'Tagged users' and annotate-user 'places a user link in the image' - people shown in the picture, not a passing mention | +| `TEMPLATE` | 1301 | the kind-33402 workout template the session was built from (the tag's own name) | +| `TEXT_TRACK` | 21, 22, 34235, 34236 | NIP-71 `text-track` names the captions/subtitles track (an encoded event or a 39307 coordinate) - the NIP's own tag word | +| `TIMED_OUT` | 9042 | past participle of Buzz's timeout action. Props: expiration, reason | +| `TIMEOUT_CLEARED` | 9043 | Buzz's 'untimeout' = 'clears a timeout'; UNTIMED_OUT is the literal participle but unreadable | +| `TRIGGERED` | 46020 | past participle of Buzz's trigger action; the 30620 workflow definition run | +| `UNARCHIVED` | 8003, 9036 | past participle of NIP-IA unarchive; split from ARCHIVED so 'is X archived' needs no property filter (rule 4) | +| `VERIFIED` | 7517 | past participle - 'the geocache naddr being verified' | +| `VERIFIER` | 37516 | NIP-CC verification tag 'public key for verifying finds' - the key that signs 7517s | +| `VIDEO` | 39307 | the NIP-71 video this caption/subtitle track belongs to (Quartz video()); the target named by its slot, like POLL or COMMUNITY | +| `VIEWER` | 30622 | NIP-DV's own word; the user a relay-signed per-viewer snapshot belongs to | +| `VOTED` | 45002 | past participle of Buzz's vote action; the voted post. Props: direction (+/-) | +| `WIKILINK` | 30041 | a resolved [[double bracket]] reference from the body (NKBIP-01 tag name; T = the target slug when no id is given) | +| `WIKILINK_AUTHOR` | 30041 | the author named in the wikilink's pubkey slot (rule 3) | +| `WINNER` | 30067, 37516 | the winning player (the tag's own name, 'winner'). OPPONENT: see 30 | +| `WOT_ROOT` | 34551 | the wot tag's 'root-pubkey' from which posters must be reachable | +| `ZAP_REQUEST` | 9735 | NIP-57's own name ('zap request') for the event embedded in the `description` tag; a content-borne event reference, so it needs a relation (props could carry the request's comment) | +| `ZAP_SPLIT` | **any kind** — seen on 1, 14, 1111, 9041, 30023 | NIP-57 Appendix G `zap` tag names a pubkey that receives zaps sent to this event; a configuration, not a payment, so it must not be ZAP_RECIPIENT (rule 4); props weight; cross-cutting (Amethyst writes it on 1, 14, 1111, 30023, 30402). EMOJI_SET: see kind 0 | + +## The classes, by package + +### `buzz` (74) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 8002 | `ArchivedIdentityEvent` | p -> ARCHIVED (U); consent[actor] -> ACTOR (U); e -> REQUEST (E); replaced-by -> REPLACED_BY (U) | ArchivedIdentityEvent.target()/consent()/requestId()/replacedBy() (PTag.parseKey, ConsentTag.parse, ETag.parseId, ReplacedByTag.parse) | Buzz NIP-IA, relay-signed delta. Props on ARCHIVED: reason. BUG: ReplacedByTag.parse and ConsentTag.parse do not check hex64 (a malformed key would become a User link). ACTOR is the 9035 author, so ACTOR duplicates (e)-[:REQUEST]->(r)-[:AUTHOR]; kept because the request is never stored (relay audits, not stores, 9035). | +| 8003 | `UnarchivedIdentityEvent` | p -> UNARCHIVED (U); consent[actor] -> ACTOR (U); e -> REQUEST (E) | UnarchivedIdentityEvent.target()/consent()/requestId() (PTag.parseKey, ConsentTag.parse, ETag.parseId) | Buzz NIP-IA relay-signed delta. Props: reason. ConsentTag actor not hex-validated. REQUEST target (9036) is never stored by the relay. | +| 9030 | `RelayAdminAddMemberEvent` | p -> ADDED_USER (U) | RelayAdminAddMemberEvent.target()/role() (PTag.parseKey, RoleTag.parse) | Buzz 'NIP-43' admin command (not the NIP-43 kind 8000); relay executes and never stores it. No `h` (tenant = connection host). Cross-group consistency: NIP-29/NIP-43 classifier should use the same ADDED/REMOVED. | +| 9031 | `RelayAdminRemoveMemberEvent` | p -> REMOVED_USER (U) | RelayAdminRemoveMemberEvent.target() (PTag.parseKey) | Buzz admin command, never stored. No `h`. | +| 9032 | `RelayAdminChangeRoleEvent` | p -> ROLE_CHANGED (U) | RelayAdminChangeRoleEvent.target()/role() (PTag.parseKey, RoleTag.parse) | UNCERTAIN: NIP-29 folds role assignment into put-user (9000), so ADDED+props role is the alternative. Owner-signed, never stored. | +| 9033 | `SetWorkspaceProfileEvent` | *none* | – | NIP-WP: only an `icon` URL tag (http(s)/data: URL) - not modelled. Only AUTHOR applies. | +| 9035 | `ArchiveRequestEvent` | p -> ARCHIVED (U); replaced-by -> REPLACED_BY (U); auth[owner] -> OWNER (U) | ArchiveRequestEvent.target()/replacedBy()/auth() (PTag.parseKey, ReplacedByTag.parse, AuthTag.parse -> OwnerAttestation.ownerPubKey) | Buzz NIP-IA request, NIP-70 protected; audited but not stored by the relay. Props on ARCHIVED: reason. ReplacedByTag.parse lacks hex64 check. OWNER link is unverified unless OwnerAttestation.verify(author) passes - suggest emitting only verified attestations. | +| 9036 | `UnarchiveRequestEvent` | p -> UNARCHIVED (U); auth[owner] -> OWNER (U) | UnarchiveRequestEvent.target()/auth() (PTag.parseKey, AuthTag.parse) | Buzz NIP-IA request, NIP-70 protected, not stored. Props: reason. | +| 9040 | `ModerationBanEvent` | p -> BANNED (U) | ModerationBanEvent.target()/expiresAt()/reason() (PTag.parseKey, ReasonTag.parse) | Mod-signed command, executed not stored; no `h`. Kind 9041 ModerationUnbanEvent collides with NIP-75 ZapGoalEvent (documented, not in EventFactory). | +| 9042 | `ModerationTimeoutEvent` | p -> TIMED_OUT (U) | ModerationTimeoutEvent.target()/expiresAt()/reason() (PTag.parseKey) | Mod-signed command, executed not stored; no `h`. | +| 9043 | `ModerationUntimeoutEvent` | p -> TIMEOUT_CLEARED (U) | ModerationUntimeoutEvent.target() (PTag.parseKey) | UNCERTAIN naming (alternative UNTIMED_OUT for symmetry with TIMED_OUT). Command, not stored. | +| 9044 | `ModerationResolveReportEvent` | report -> RESOLVED (E) | ModerationResolveReportEvent.report()/status()/action()/reason() (ReportTag.parse) | Custom `report` tag (not `e`) by design. BUG: ReportTag.parse does not check hex64. Command, not stored. No REPORTED_* links derivable without the 1984. | +| 10100 | `AgentProfileEvent` | *none* | – | Replaceable, content-only (loose JSON). Content `channel_ids` are group UUIDs (would be GROUP T via content if content JSON refs are ever modelled) - not modelled; schema flagged conservative. Only AUTHOR/ADDRESS. | +| 13535 | `ArchivedIdentitiesListEvent` | p -> ARCHIVED (U) | ArchivedIdentitiesListEvent.archivedIdentities() (PTag.parseKey) | Relay-signed replaceable snapshot, one bare `p` per archived identity; list entries named as the list names them (archived identities). | +| 20002 | `TypingIndicatorEvent` | h -> GROUP (T); e[root] -> ROOT (E); e[reply] -> PARENT (E) | TypingIndicatorEvent.channelId()/threadRootId()/threadReplyId() (GroupIdTag.parse, MarkedETag.parseAllThreadTags) | Ephemeral, never stored (graph may skip). Builder emits root only when root != reply, so a lone `reply` e is both ROOT and PARENT. parseAllThreadTags does not check hex64 on the id. | +| 24134 | `PairingEvent` | p -> RECIPIENT (U) | PairingEvent.recipientPubKey() (PTag.parseKey) | Buzz NIP-AB, ephemeral; the `p` is an EPHEMERAL session key, not a user identity (graph may want to skip ephemeral kinds). | +| 24200 | `ObserverFrameEvent` | p -> RECIPIENT (U); agent -> AGENT (U) | ObserverFrameEvent.recipientPubKey()/agentPubKey() (PTag.parseKey, AgentTag.parse) | Buzz NIP-AO, ephemeral. Telemetry: AGENT == author (self-link); control: AGENT == RECIPIENT (duplicate). Props: frame (telemetry/control). BUG: AgentTag.parse lacks hex64 check. | +| 24810 | `HuddleReactionEvent` | h -> GROUP (T) | HuddleReactionEvent.channelId() (GroupIdTag.parse) | Ephemeral. `h` is the EPHEMERAL huddle channel UUID, not the parent timeline channel. `reaction`/`sender_name` are values; NIP-30 `emoji` URL not modelled. No reacted target. | +| 30174 | `EngramEvent` | p -> OWNER (U) | EngramEvent.ownerPubKey() (PTag.parseKey) | Buzz NIP-AE. TRAP: `d` is a blinded 64-hex HMAC that looks like an id/pubkey by shape - must never be shape-matched into a link. | +| 30175 | `PersonaEvent` | content{respond_to_allowlist} -> ALLOWED (U) | new parser needed (PersonaEvent.personaOrNull()?.respondToAllowlist) | UNCERTAIN: reference lives in plaintext content JSON, not tags; entries appear to be pubkey hex (test uses a truncated key) - validate hex64. `d` = persona slug (ADDRESS). | +| 30176 | `TeamEvent` | content{persona_ids} -> MEMBER (A) | new parser needed (TeamEvent.teamOrNull()?.personaIds -> Address(30175, author, id)) | UNCERTAIN: content JSON; persona ids assumed to be 30175 `d` slugs under the SAME author (a team groups the owner's personas) - confirm against Buzz team_events.rs. | +| 30177 | `ManagedAgentEvent` | d -> AGENT (U); content{persona_id} -> PERSONA (A); content{respond_to_allowlist} -> ALLOWED (U) | ManagedAgentEvent.agentPubKey() (dTag); new parser needed for agentOrNull()?.personaId / respondToAllowlist | The `d` tag IS the agent pubkey (like NIP-85 d -> SUBJECT); dTag() not hex-validated. UNCERTAIN: persona_id assumed to be the owner's own 30175 slug. | +| 30300 | `EventReminderEvent` | *none* | – | Buzz NIP-ER: public tags are d/not_before/expiration/alt only. The reminder target (id / a) is inside self-encrypted content - private, not modelled (would be REMINDED if ever decrypted). | +| 30350 | `PushLeaseEvent` | *none* | – | Buzz NIP-PL: d = installation id, `exec` = gateway key id (opaque, not a Nostr pubkey), expiration; descriptor encrypted. Only AUTHOR/ADDRESS. | +| 30620 | `WorkflowDefEvent` | h -> GROUP (T) | WorkflowDefEvent.channel() (GroupIdTag via firstTagValue) | d = workflow UUID (ADDRESS). `name` is a value. workflowChannel() uses firstTagValue with no emptiness check. | +| 30622 | `DmVisibilityEvent` | p -> VIEWER (U); h -> HIDDEN (T) | DmVisibilityEvent.viewerFromPTag()/hiddenChannels() (PTag.parseKey, GroupIdTag.parse) | Relay-signed addressable; d == p == viewer pubkey (duplicate, d not validated). HIDDEN extended to a T target (group id): these `h` are hidden DMs, not scope, so they are HIDDEN not GROUP. | +| 39006 | `WindowBoundsEvent` | h -> GROUP (T) | WindowBoundsEvent.channelId() (GroupIdTag.parse) | Relay-synthesized, never stored. d = :. content next_cursor.id is a pagination boundary event id - not modelled (not a statement). | +| 40002 | `StreamMessageV2Event` | h -> GROUP (T); p -> MENTION (U); e[root] -> ROOT (E); e[reply] -> PARENT (E) | StreamMessageV2Event.channel()/mentions() (GroupIdTag.parse, PTag.parseKey); buzzThreadRoot()/buzzThreadReply() in buzz/threading (not exposed on the class) | Thread e-tags per Buzz thread_tags (shared with 45003, per buzz/threading KDoc) but the Quartz class/builder neither reads nor writes them - GAP. Lone `reply` marker = direct reply, so it is both ROOT and PARENT. `broadcast` is a flag. Content nostr: URIs not parsed by the class. | +| 40003 | `StreamMessageEditEvent` | h -> GROUP (T); e -> EDITED (E) | StreamMessageEditEvent.channel()/editedMessage() (ETag.parseId) | Buzz build_edit. | +| 40004 | `StreamMessagePinnedEvent` | h -> GROUP (T); e -> PIN (E) | StreamMessagePinnedEvent.channel()/pinnedMessage() (ETag.parseId) | PIN extended to a channel pin. Tag shape inferred (no Buzz builder). | +| 40005 | `StreamMessageBookmarkedEvent` | h -> GROUP (T); e -> BOOKMARK (E) | StreamMessageBookmarkedEvent.channel()/bookmarkedMessage() (ETag.parseId) | Tag shape inferred (no Buzz builder). | +| 40006 | `StreamMessageScheduledEvent` | h -> GROUP (T) | StreamMessageScheduledEvent.channel() (GroupIdTag.parse) | Schema inferred; only `h` modelled. | +| 40007 | `StreamReminderEvent` | h -> GROUP (T); p -> RECIPIENT (U); e -> REMINDED (E) | StreamReminderEvent.channel()/recipients()/targetMessage() (PTag.parseKey, ETag.parseId) | UNCERTAIN: no Buzz builder; `e` target is read but never written by Quartz's builder. RECIPIENT = 'the user the reminder is for'. | +| 40008 | `StreamMessageDiffEvent` | h -> GROUP (T); l -> TAG (T) | StreamMessageDiffEvent.channel()/diffMeta() (GroupIdTag.parse, LanguageTag.parse) | `l` here is the diff's programming language, NOT NIP-32 (target Tag(l, lang)). repo (URL), commit/parent-commit (git SHAs), file, branch, pr are not Nostr entities - not modelled. | +| 40099 | `SystemMessageEvent` | h -> GROUP (T); content{actor} -> ACTOR (U); content{target}[member_joined] -> ADDED_USER (U); content{target}[member_removed\|member_left] -> REMOVED_USER (U); content{target_event_id}[message_deleted] -> DELETED (E); content{participants}[dm_created] -> PARTICIPANT (U) | SystemMessageEvent.channel()/payload() (SystemMessagePayload.actor/target/targetEventId/participants); new parser needed to map by type | Relay-signed. UNCERTAIN: all refs are in content JSON, keyed by payload.type; no hex validation on actor/target/target_event_id/participants. Props: type, reason_code. | +| 40100 | `CanvasEvent` | h -> GROUP (T) | CanvasEvent.channel() (GroupIdTag.parse) | Buzz build_set_canvas; markdown content, no nostr: parsing. | +| 40901 | `ChannelSummaryEvent` | h -> GROUP (T) | ChannelSummaryEvent.channel() (GroupIdTag.parse) | Relay-only sidecar; schema unconfirmed (no Buzz emitter). content channel_id duplicates `h`. | +| 40902 | `PresenceSnapshotEvent` | content{entries[].pubkey} -> SUBJECT (U) | new parser needed (PresenceSnapshotEvent.snapshot()?.entries) | UNCERTAIN: relay-only sidecar, schema unconfirmed (no Buzz emitter; relay answers with 20001s whose `p` is the 'subject'). SUBJECT extended from NIP-85 to a relay's presence statement; props status, last_seen_at. No `h`. | +| 41001 | `DmCreatedEvent` | d -> GROUP (T); p -> PARTICIPANT (U) | DmCreatedEvent.dmId()/participants() (DTag via firstTagValue, PTag.parseKey) | Relay-signed. The DM id rides in `d` on a REGULAR kind (not addressable) - emit as Tag("h", id) so it joins the DM's GROUP links. dmId() returns "" when absent (must not emit). | +| 41010 | `DmOpenEvent` | p -> PARTICIPANT (U) | DmOpenEvent.participants() (PTag.parseKey) | Command (1-8 participants); relay replies with 41001. | +| 41011 | `DmAddMemberEvent` | h -> GROUP (T); p -> ADDED_USER (U) | DmAddMemberEvent.channelId()/member() (GroupIdTag.parse, PTag.parseKey) | – | +| 41012 | `DmHideEvent` | h -> HIDDEN (T) | DmHideEvent.channelId() (GroupIdTag.parse) | HIDDEN (NIP-28 'hide message') extended to a T target: the `h` is the DM being hidden, the object of the action, not just scope. | +| 42000 | `ProductFeedbackEvent` | *none* | – | Only `category` value and optional `imeta` (media URLs, not modelled). Never stored by the relay. | +| 43001 | `JobRequestEvent` | h -> GROUP (T); p -> AGENT (U) | JobRequestEvent.channel()/target() (GroupIdTag.parse, PTag.parseKey) | UNCERTAIN: 43001-43006 reserved in Buzz with no builder; Quartz tag layout is best-effort. | +| 43002 | `JobAcceptedEvent` | e -> REQUEST (E); h -> GROUP (T); p -> REQUEST_AUTHOR (U) | JobAcceptedEvent.jobRequest()/channel()/requester() (ETag.parseId, GroupIdTag.parse, PTag.parseKey) | UNCERTAIN schema (reserved kind). | +| 43003 | `JobProgressEvent` | e -> REQUEST (E); h -> GROUP (T) | JobProgressEvent.jobRequest()/channel() (ETag.parseId, GroupIdTag.parse) | UNCERTAIN schema. Props: status. | +| 43004 | `JobResultEvent` | e -> REQUEST (E); h -> GROUP (T); p -> REQUEST_AUTHOR (U) | JobResultEvent.jobRequest()/channel()/requester() | UNCERTAIN schema. Props: status. | +| 43005 | `JobCancelEvent` | e -> REQUEST (E); h -> GROUP (T) | JobCancelEvent.jobRequest()/channel() | UNCERTAIN schema. | +| 43006 | `JobErrorEvent` | e -> REQUEST (E); h -> GROUP (T); p -> REQUEST_AUTHOR (U) | JobErrorEvent.jobRequest()/channel()/requester() | UNCERTAIN schema. Props: status. | +| 44100 | `MemberAddedNotificationEvent` | p -> ADDED_USER (U); h -> GROUP (T); content{actor} -> ACTOR (U) | MemberAddedNotificationEvent.target()/channel()/actor() (PTag.parseKey, firstTagValue(h), MembershipNotificationContent.parse) | Relay-signed. Self-join reports actor == target (ACTOR == ADDED). | +| 44101 | `MemberRemovedNotificationEvent` | p -> REMOVED_USER (U); h -> GROUP (T); content{actor} -> ACTOR (U) | MemberRemovedNotificationEvent.target()/channel()/actor() | Relay-signed. | +| 44200 | `AgentTurnMetricEvent` | p -> OWNER (U); agent -> AGENT (U) | AgentTurnMetricEvent.ownerPubKey()/agentPubKey() (PTag.parseKey, AgentTag.parse) | Buzz NIP-AM. AGENT normally == author (self-link). AgentTag.parse lacks hex64 check. | +| 45001 | `ForumPostEvent` | h -> GROUP (T); p -> MENTION (U) | ForumPostEvent.channel()/mentions() (firstTagValue(h), PTag.parseKey) | Thread root of a forum thread. | +| 45002 | `ForumVoteEvent` | h -> GROUP (T); e -> VOTED (E) | ForumVoteEvent.channel()/target()/direction() (ETag.parseId) | Alternative: REACTED (a +/- vote is reaction-like); no author `p`, so no VOTED_AUTHOR on the wire. | +| 45003 | `ForumCommentEvent` | h -> GROUP (T); e[root] -> ROOT (E); e[reply] -> PARENT (E); p -> MENTION (U) | ForumCommentEvent.channel()/threadRoot()/replyTo()/mentions() (buzzThreadRoot/buzzThreadReply, PTag.parseKey) | Buzz thread_tags: a direct reply has ONLY a `reply` marker (root == parent), so the method must emit ROOT too when no root marker is present (threadRoot() returns null there). | +| 46001 | `WorkflowTriggeredEvent` | h -> GROUP (T) | WorkflowTriggeredEvent.channel() | Relay-emitted lifecycle; run/step ids not modelled (schema not fixed). | +| 46002 | `WorkflowStepStartedEvent` | h -> GROUP (T) | WorkflowStepStartedEvent.channel() | Same as 46001. | +| 46003 | `WorkflowStepCompletedEvent` | h -> GROUP (T) | WorkflowStepCompletedEvent.channel() | Same as 46001. | +| 46004 | `WorkflowStepFailedEvent` | h -> GROUP (T) | WorkflowStepFailedEvent.channel() | Same as 46001. | +| 46005 | `WorkflowCompletedEvent` | h -> GROUP (T) | WorkflowCompletedEvent.channel() | Same as 46001. | +| 46006 | `WorkflowFailedEvent` | h -> GROUP (T) | WorkflowFailedEvent.channel() | Same as 46001. | +| 46007 | `WorkflowCancelledEvent` | h -> GROUP (T) | WorkflowCancelledEvent.channel() | Same as 46001. | +| 46010 | `WorkflowApprovalRequestedEvent` | h -> GROUP (T); p -> APPROVER (U) | WorkflowApprovalRequestedEvent.channel()/approver() (PTag.parseKey) | Relay-signed needs-action item. | +| 46011 | `WorkflowApprovalGrantedEvent` | h -> GROUP (T) | WorkflowApprovalGrantedEvent.channel() | Relay lifecycle event; no link to the 46010/46030 modelled. | +| 46012 | `WorkflowApprovalDeniedEvent` | h -> GROUP (T) | WorkflowApprovalDeniedEvent.channel() | Same as 46011. | +| 46020 | `WorkflowTriggerEvent` | d -> TRIGGERED (A) | WorkflowTriggerEvent.workflowId() (DTag via firstTagValue) -> Address(30620, author, d) | UNCERTAIN: the `d` (on a REGULAR kind) holds only the workflow UUID; address assumes owner == author, which the relay enforces ('only the workflow owner may trigger'). | +| 46030 | `ApprovalGrantEvent` | *none* | – | `d` (on a regular kind) = approval token hash - opaque, not an event id, not modelled. Only AUTHOR. | +| 46031 | `ApprovalDenyEvent` | *none* | – | Same as 46030. | +| 48001 | `AuditEntryEvent` | p -> ACTOR (U); object -> AUDITED (E\|T) | AuditEntryEvent.actor()/objectId() (PTag.parseKey, ObjectTag.parse) | UNCERTAIN: schema is a Quartz-side projection - Buzz never emits 48001 on the wire. `object` is polymorphic (event id, channel UUID, media sha256): E only when the action targets an event, else T; a 64-hex sha256 must not be shape-matched as an event. | +| 48100 | `HuddleStartedEvent` | h -> GROUP (T) | HuddleStartedEvent.channelId() | Content ephemeral_channel_id (UUID) not modelled. | +| 48101 | `HuddleParticipantJoinedEvent` | h -> GROUP (T); p -> PARTICIPANT (U) | HuddleParticipantJoinedEvent.channelId()/participant() (PTag.parseKey) | Relay-signed; participant is the `p`, not the author. | +| 48102 | `HuddleParticipantLeftEvent` | h -> GROUP (T); p -> PARTICIPANT (U) | HuddleParticipantLeftEvent.channelId()/participant() | Relay-signed. PARTICIPANT for both join and leave: the kind says which. | +| 48103 | `HuddleEndedEvent` | h -> GROUP (T); p -> PARTICIPANT (U) | HuddleEndedEvent.channelId()/participant() | `p` = last participant (optional). | +| 48106 | `HuddleGuidelinesEvent` | h -> GROUP (T) | HuddleGuidelinesEvent.channelId() | Schema uncertain (no Buzz constructor). | + +### `experimental` (57) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 31 | `ExternalCitationEvent` | open_timestamp -> OPEN_TIMESTAMP (E); g -> TAG (T) | ExternalCitationEvent.openTimestamp() (CitationTags.OPEN_TIMESTAMP; no 64-hex validation); CitationEvent.geohash() (CitationTags.GEOHASH) | Spec: silberengel/jumble citation vocabulary (no NIP). u/url (the cited URL) is not modelled in v1 (not an allowlisted value tag; would be TAG if u joins the list). Bug: openTimestamp() returns any non-empty string, no id-shape check. Kind 30 (internal citation) deliberately unmodelled: collides with Jester chess. | +| 32 | `HardcopyCitationEvent` | g -> TAG (T) | CitationEvent.geohash() (CitationTags.GEOHASH) | Spec: jumble citations (no NIP). doi, published_in, author are free-text values, not link targets. Only g carries a link, via the shared base. | +| 33 | `PromptCitationEvent` | g -> TAG (T) | CitationEvent.geohash() (CitationTags.GEOHASH) | Spec: jumble citations (no NIP). u (conversation URL) and llm are not modelled. | +| 82 | `FhirResourceEvent` | *none* | – | No spec reference in KDoc; FHIR JSON payload in content, class reads no tags. Anything an initializer adds is unparsed. | +| 1010 | `TextNoteModificationEvent` | e -> EDITED (E); p -> EDITED_AUTHOR (U) | editedNote() = firstTaggedEvent(); p read only via generic taggedUsers (new parser needed for the p slot) | Draft edits NIP (kind 1010). Only the FIRST e is read (firstTaggedEvent). summary is a value tag. Draft-plan correction: the '1010 claimed by two classes' note is wrong: GoodWikiRelayListEvent.KIND is 10102, not 1010, and EventFactory types 1010 only as TextNoteModificationEvent (the text scan matched the 10102 prefix). | +| 1064 | `FileStorageEvent` | *none* | – | NIP-95 draft (base64 file blob in content). Only m (mime) tag. | +| 1065 | `FileStorageHeaderEvent` | e -> FILE_DATA (E) | dataEventIds() / dataEvent() (ETag::parseId / ETag::parse) | NIP-95 draft (never merged). x/url/image/thumb/fallback/service/magnet are hashes and URLs, not modelled. The e tag carries an author slot (EventHintBundle.toETag) that is a hint, not a separate statement. | +| 1163 | `ProfileGalleryEntryEvent` | e -> SOURCE (E) | fromEvent() (ETag::parseId) | Amethyst profile-gallery kind (no NIP). url/x/ox/imeta-style fields are not modelled. No p for the source author; if one is added later it would be SOURCE_AUTHOR (rule 3). | +| 1301 | `WorkoutRecordEvent` | exercise[coordinate] -> EXERCISE (A); template -> TEMPLATE (A); t -> HASHTAG (T) | exerciseSetAddressIds() (ExerciseSetTag::parseAddressId), templateAddressId() (TemplateTag::parseAddressId), addressHints()/linkedAddressIds(); hashtags via generic HashtagTag | NIP-101e (draft) + RUNSTR dialect. RUNSTR exercise tag is a plain verb (not a link); ExerciseSetTag.isCoordinate distinguishes. Oddity: builder writes a d tag on a regular (non-addressable) kind 1301, so d has no replaceability meaning. Implements RootScope (can be a NIP-22 root). | +| 1315 | `RoadEventReportEvent` | t -> HASHTAG (T); g -> TAG (T) | roadEventTypeCode() (RoadEventTypeTag::parseCode), geohashes() | Roadstr draft NIP (jooray/roadstr nips/roadstr.md). t carries the road-event type code (police, accident...), a category rather than a free hashtag; HASHTAG per the t rule. lat/lon/expiration are values. | +| 1316 | `RoadEventConfirmationEvent` | e -> CONFIRMED (E); g -> TAG (T) | reportId() / linkedEventIds() (RoadReportTag::parseId), geohashes() | Roadstr draft NIP. RoadReportTag reads an author at e[3] (Quartz writes the report author there); the spec does not define it, so it stays a hint, not a CONFIRMED_AUTHOR link. UNCERTAIN: if denials must be counted separately, split per rule 4 (CONFIRMED / DENIED). | +| 1808 | `AudioHeaderEvent` | *none* | – | No spec reference. download_url / stream_url / waveform only: URLs and data, not modelled. | +| 2473 | `BirdDetectionEvent` | i -> TAG (T); g -> TAG (T) | speciesReference() (firstTagValue("i"), http(s)-filtered); Event.geohashes() | Birdstar app kind (no NIP). i is a Wikidata species URL (NIP-73 style). n (scientific name) is a value, not modelled. | +| 3063 | `SoftwareAssetEvent` | i -> TAG (T) | appId() (AppIdTag::parse) | NIP-82 (draft; 32267 is listed in the NIPs README but 82.md is not on master). i is the application's d-tag identifier, not a NIP-73 id. UNCERTAIN: it effectively names the address 32267::; a derived APPLICATION (A) link would be more useful than TAG. url/x/f/apk_certificate_hash are not modelled. | +| 4312 | `AdminCommandEvent` | a -> ROOT (A); p[action=kick] -> KICKED (U); p[action=mute] -> CHANNEL_MUTED (U) | room() and targetPubkey() (raw first a / first p, no validation); action() | nostrnests EGG-07 (ephemeral kind). a names the kind-30312 room; ROOT chosen for consistency with 10312 presence and 1311 chat, but this a carries no root marker: UNCERTAIN (alternative: a new ROOM). Rule 4 split kick vs mute on the same U target. Weak parsing: room()/targetPubkey() take the first a/p without shape checks. | +| 6969 | `ZapPollEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); e[mention] -> MENTION (E); q -> QUOTE (E,A); a -> MENTION (A); p -> MENTION (U); content nostr: -> MENTION (E,A,U) | BaseThreadedEvent.root()/reply()/threadTags() (MarkedETag), QTag::parseEventId/parseAddressId, ATag::parseAddressId, PTag::parseKey, citedNIP19() | Zap polls (old NIP-69 draft); a kind-1-shaped threaded note plus poll_option tags, so it should take kind 1's classification verbatim. UNCERTAIN: unmarked a tags treated as MENTION pending kind 1's decision. Known Quartz bug applies: QTag.parseAddressId rejects every address. Votes are zaps (9734/9735) carrying poll_option; not this class. | +| 9998 | `ListHeaderEvent` | *none* | – | Decentralized Lists (Tapestry pre-NIP, nous-clawds4/tapestry protocols/nips/decentralized-lists.md). names/titles/slugs/required/allowed/item-kind/description are values; item-kind names a kind, not an entity. Items point at the header, not vice versa. | +| 9999 | `ListItemEvent` | z -> PARENT_LIST (E,A,T); p -> ITEM (U); e -> ITEM (E); a -> ITEM (A); t -> ITEM (T) | parentLists() (ParentListTag::parse/classify: EventId \| Coordinate \| Name), itemPubKeys() (PTag), itemEvents() (ETag), itemAddresses() (ATag::parse), itemStrings() (HashtagTag); hint providers | Decentralized Lists spec. t here is a list VALUE, case-preserved, not a hashtag (Quartz README says so), hence ITEM (T) instead of HASHTAG. e items may carry the author at e[3] (itemEvent pads the relay slot): a hint. A 9999 may also declare a list (nonstandard method) and then carries header tags (values only). Cross-cutting: Event.dListParents() reads z on ANY kind (Cross-NIP Compatibility), so PARENT_LIST can come from foreign kinds too. | +| 10023 | `EphemeralChatListEvent` | *none* | – | Amethyst ephemeral-chat room list. group tags are [room name, relay URL] pairs: relay-scoped identifiers, not Nostr entities, so not modelled in v1 (would be SUBSCRIBED (T) if room ids become targets). Private rooms live NIP-44 encrypted in content. | +| 11871 | `AttestorProficiencyEvent` | k -> TAG (T) | kinds() (recommendation.tags.KindTag::parse) | Attestations draft NIP (kinds 11871/31871/31872/31873; spec not fetched). The attestor declares the kinds it can attest. | +| 12473 | `BirdexEvent` | i -> TAG (T) | species() (adjacent n/i pairing, asWebReference()) | Birdstar app kind (no NIP). Each i is the Wikidata URL of a species on the life list; n is a value. Arguably list entries (MEMBER (T)), but i -> TAG per the plain-tag rule. | +| 20000 | `GeohashChatEvent` | g -> TAG (T); t[teleport] -> HASHTAG (T) | geohash() (GeoHashTag::parse), isTeleported() (TeleportTag::match) | Bitchat location channels (ephemeral). g is the exact channel cell (single tag, no mip-map). t=teleport is a flag, not a topic; HASHTAG per the t rule (could equally be dropped). n (nickname) is a value. Authors are per-geohash derived keys, unlinkable to the main npub by design. | +| 20001 | `GeohashPresenceEvent` | g -> TAG (T) | geohash() (GeoHashTag::parse) | Bitchat presence (ephemeral). Kind 20001 is also buzz PresenceUpdateEvent; EventFactory disambiguates by the presence of a g tag, so links() must only apply when the class is actually GeohashPresenceEvent. | +| 21001 | `OfferEvent` | p -> RECIPIENT (U); e -> REQUEST (E) | recipientPubKey() (PTag::parseKey), requestId() (ETag::parseId) | CLINK Offers (shocknet/clink specs/clink-offers.md). Ephemeral; content NIP-44 to the p. Same kind for request, response and receipt. | +| 21002 | `DebitEvent` | p -> RECIPIENT (U); e -> REQUEST (E) | recipientPubKey() (PTag::parseKey), requestId() (ETag::parseId) | CLINK Debits (shocknet/clink specs/clink-debits.md). Ephemeral, NIP-44 content. | +| 21003 | `ManageEvent` | p -> RECIPIENT (U); e -> REQUEST (E) | recipientPubKey() (PTag::parseKey), requestId() (ETag::parseId) | CLINK Manage (shocknet/clink specs/clink-manage.md). Ephemeral, NIP-44 content. | +| 23333 | `EphemeralChatEvent` | *none* | – | Ephemeral chat: d = room name and relay = relay URL (RoomTag, RelayTag); neither is a Nostr entity, so not modelled in v1. Note d is used on an ephemeral kind as a room label, not an address. | +| 23903 | `WakeUpEvent` | e -> ABOUT (E); p -> ABOUT_AUTHOR (U); k -> TAG (T) | eventIds() (ETag::parseId), authorKeys() (PTag::parseKey), kinds() (KindTag) | Amethyst experimental push/wake kind (ephemeral, no spec). notifies() returns true for everyone, so p must not be read as RECIPIENT. | +| 30040 | `PublicationIndexEvent` | a -> MEMBER (A); e -> MEMBER (E); p -> MENTION (U); t -> HASHTAG (T); A/E -> SOURCE (A,E) | sections() / PublicationSectionRef.fromTags (a and e, in order), linkedAddressIds() (ATag), linkedPubKeys() (PTag), topics() (hashtags()); A/E: new parser needed | NKBIP-01 (GitCitadel; spec not fetched). MEMBER as in the draft (30040 already listed). Props for MEMBER: order, inline title, level. UNCERTAIN: p semantics unverified (author/contributor pubkey vs mention); author tag is a human name, not a pubkey. EventHintProvider missing: e sections are not in any hint provider. | +| 30041 | `PublicationContentEvent` | T/c -> PUBLICATION (A); wikilink -> WIKILINK (E,T); wikilink[pubkey] -> WIKILINK_AUTHOR (U) | publicationAddress() (T, else c, + own pubkey -> 30040 address), wikilinks() (WikilinkTag::parse) | NKBIP-01. PUBLICATION target is DERIVED (T/c carry only the index d; pubkey assumed = section author), so the link is an inference. AsciiDoc content: no citedNIP19 parsing, so no content MENTIONs today. | +| 30045 | `BookshelfDirectoryEvent` | a -> MEMBER (A); e -> MEMBER (E) | items() (PublicationSectionRef.fromTags), linkedAddressIds() (ATag) | Bookshelf directory (no NIP; already MEMBER in the draft). Hint gap: e entries are not in any EventHintProvider. | +| 30053 | `NNSEvent` | *none* | – | NNS (Nostr name system) record: ip4/ip6/version values only. Bug: neither build() writes a d tag on this addressable kind (every record collapses onto d=""). | +| 30142 | `LearningResourceEvent` | t -> HASHTAG (T) | topics() (hashtags()) | No NIP (edu publishers, schema.org-style flat tags). about:id / learningResourceType:id are vocabulary URIs, encoding:contentUrl a URL: not modelled. | +| 30296 | `InteractiveStoryPrologueEvent` | option -> OPTION (A) | options() (StoryOptionTag::parse) | Interactive stories (no NIP in KDoc). RootScope. StoryOptionTag.parse keeps the relay as a raw string (not normalized). | +| 30297 | `InteractiveStorySceneEvent` | option -> OPTION (A) | options() (StoryOptionTag::parse) | Interactive stories. RootScope. | +| 30298 | `InteractiveStoryReadingStateEvent` | A -> ROOT (A); a -> CURRENT_SCENE (A) | root() (RootSceneTag::parse, uppercase A), currentScene() (ATag::parseAddress) | d = the root story address (a reference in d). BUGS: build() calls rootScene(rootTag), which writes a lowercase a (ATag.toATagArray), and currentScene() then addUnique-replaces it, so Quartz-built events carry no A and root() returns null; build() also swaps storyImage(summary)/storySummary(image). | +| 30392 | `UserTrustedListEvent` | p -> MEMBER (U); a -> ABOUT (A); observer -> OBSERVER (U); source-tag -> SOURCE_TAG (E) | members() (PubKeyMemberTag), aboutAddresses() (ATag), observer() (ObserverTag), sourceTag() (SourceTag) | Tapestry Trusted Lists (+10 of NIP-85 kinds). MEMBER already in the draft; props score (0..100). source-tag also carries the tag's author and slug (hint/provenance; no link proposed). Hint providers ignore observer and source-tag. | +| 30393 | `EventTrustedListEvent` | e -> MEMBER (E); a -> ABOUT (A); p -> ABOUT (U); observer -> OBSERVER (U); source-tag -> SOURCE_TAG (E) | members() (EventMemberTag), aboutAddresses(), aboutPubKeys(), observer(), sourceTag() | Tapestry Trusted Lists. By convention the p is the observer (for #p discovery), duplicating the observer tag; kept as ABOUT (the slot's role). e[3] is a score, not a NIP-10 marker. | +| 30394 | `AddressableTrustedListEvent` | a -> MEMBER (A); p -> ABOUT (U); observer -> OBSERVER (U); source-tag -> SOURCE_TAG (E) | members() (AddressMemberTag), aboutPubKeys(), observer(), sourceTag() | Tapestry Trusted Lists. AddressMemberTag.parseAddressId returns any non-empty a value (no coordinate-shape check). | +| 30395 | `ExternalIdTrustedListEvent` | i -> MEMBER (T); a -> ABOUT (A); p -> ABOUT (U); observer -> OBSERVER (U); source-tag -> SOURCE_TAG (E) | members() (ExternalIdMemberTag), aboutAddresses(), aboutPubKeys(), observer(), sourceTag() | Tapestry Trusted Lists. i members are NIP-73 external ids: MEMBER (T) rather than TAG, since they are list entries (MEMBER needs T added to its targets). | +| 30817 | `NipTextEvent` | a[fork] -> FORK (A); e[fork] -> FORK (E); q -> QUOTE (E,A); a -> MENTION (A); p -> MENTION (U); k -> TAG (T); content nostr: -> MENTION (E,A,U) | forkFromAddress() (ForkTag::parseAddress), forkFromVersion() (MarkedETag.parseForkedEventId), QTag::parseEventId/parseAddressId, ATag::parseAddressId, citedNIP19(), kinds (KindTag) | NIPs-on-Nostr (draft). FORK extends from kind 1 to A targets. BUG: ForkTag.parse / parseValidAddress require kind 34550 (CommunityDefinitionEvent, copy-paste from NIP-72) instead of 30817; forkFromAddress() uses parseAddress, which checks 30817 but not the fork marker, so any a to a 30817 is read as the fork source. Known QTag.parseAddressId bug applies. | +| 31337 | `AudioTrackEvent` | p -> PARTICIPANT (U) | participants() (ParticipantTag::parse) | Zapstr-style audio track (no NIP in KDoc). Zapstr p tags carry a role (Host/Artist) at p[3], which ParticipantTag ignores: should become a prop. c is a type/genre value (not allowlisted); media/cover URLs not modelled. | +| 31871 | `AttestationEvent` | e -> ASSERTION (E); a -> ASSERTION (A); request -> REQUEST (A) | assertionEventId() (ETag), assertionAddrId() (ATag), requestId() / requestAddress() (RequestTag) | Attestations draft (spec not fetched). BUG: RequestTag is copy-pasted from NIP-72 ApprovedAddressTag: parse() returns ApprovedAddressTag and rejects 34550 addresses (meaningless here); linkedAddressIds()/addressHints() ignore the request tag. e carries the attested author at e[3] (hint). | +| 31872 | `AttestationRequestEvent` | e -> ASSERTION (E); a -> ASSERTION (A); p -> ATTESTOR (U) | assertionEventId() (ETag), assertionAddrId() (ATag), linkedPubKeys() (PTag); attestorPubKeys builder | Attestations draft. Naming bug: assertionPubkey()/assertionPTag() read the p, which the builder fills with ATTESTORS, not the assertion's author. cashu_token is a value (payment), not modelled. | +| 31873 | `AttestorRecommendationEvent` | d -> RECOMMENDED (U); k -> TAG (T) | new parser needed (the attestor pubkey is only in dTag(); builder dTag(attestorPubKey)); kinds() (KindTag) | Attestations draft. Reuses RECOMMENDED (NIP-89) with a new U target: the recommended attestor, props kinds. A pubkey in a d tag is invisible to every hint provider and to #p filters; no accessor validates it is 64-hex. | +| 31987 | `RelayReviewEvent` | *none* | – | Relay review (no merged NIP). The reviewed thing is a relay URL (d, or relay tag): relays are not link targets in v1. If relays become nodes, it would be RATED. | +| 32176 | `BlossomPieceIndexEvent` | r -> TAG (T) | url() (firstValue("r")) | No NIP. x (whole-file hash), b (piece hashes) and blossom (servers) are not modelled; note b here means piece hash, unrelated to Tapestry's b (inherit-from). | +| 32267 | `SoftwareApplicationEvent` | a -> RELEASE (A); t -> HASHTAG (T) | appLinks() (ATag::parse), topics() (HashtagTag) | NIP-82 draft. UNCERTAIN: a semantics not documented in Quartz (appLink builder has no KDoc); zapstore apps a-tag their latest 30063 release, hence RELEASE. repository/url/icon/image are URLs, f platform a value. | +| 33401 | `ExerciseTemplateEvent` | *none* | – | NIP-101e draft exercise template (POWR). Referenced by 1301 EXERCISE links; carries only values (title, format, format_units, equipment, difficulty). | +| 33863 | `FundraiserEvent` | t -> HASHTAG (T) | topics() (hashtags()) | Agora app kind (Ditto, no NIP). w = on-chain donation addresses, goal/deadline values: not modelled. Zaps to this event arrive as ZAPPED from 9735s. | +| 34139 | `MusicPlaylistEvent` | a -> CURATED (A); t -> HASHTAG (T) | trackAddresses() (ATag::parseAddress filtered to kind 36787); hashtags | No NIP in KDoc. A playlist is a published curation set of tracks, like 30004-30006, so CURATED (props: order). UNCERTAIN alternative per rule 7: TRACK. Non-track a tags are preserved by edit() but not interpreted. | +| 34238 | `VideoCollaborationEvent` | a -> COLLABORATED (A); p -> COLLABORATED_AUTHOR (U) | video() (ATag::parseAddress), videoAuthor() (PTag::parseKey) | divine-web / divine-mobile convention (no NIP). d may also be the video coordinate (divine-mobile). Listed as REFERENCE-only in the draft; now classified. UNCERTAIN: if declined answers matter to queries, split per rule 4. | +| 34259 | `EntityRatingEvent` | d -> RATED (E,A,U,T); a -> RATED (A); A -> RATED (A); e -> RATED (E); p -> RATED_AUTHOR (U); k -> TAG (T) | targetIdentifier()/mark() (d with mark prefix), targetAddress() (ATag, RootAddressTag), targetEventId() (ETag), targetAuthor() (PTag), targetKind() (ReplyKindTag) | abh3po/nostr-polls XYZ.md. d target type depends on m (event id -> E, profile -> U, coordinate -> A, hashtag/books/movies/relay -> T; relay ones fall under the not-modelled rule). Props: stars/rating, mark. a and A duplicate the same coordinate: emit one link. | +| 36787 | `MusicTrackEvent` | t -> HASHTAG (T) | hashtags (HashtagTag); builder hashtag("music") | No NIP in KDoc. artist/album are free-text names, url/video/image URLs. edit() mentions zap split tags being preserved but no accessor reads them (NIP-57 zap splits would need a relation decided for all kinds). | +| 38192 | `Ps1SaveEvent` | *none* | – | PS1 memory-card blocks (no NIP). m (memory card id), x (hash), block/state/filename/region/title are values. | +| 39998 | `AddressableListHeaderEvent` | b -> INHERIT_FROM (A); concept-graph -> CONCEPT_GRAPH (A) | inheritFrom() (InheritFromTag::parse), conceptGraph() (ConceptGraphTag::parse, else computed) | Tapestry drafts on Decentralized Lists. CONCEPT_GRAPH is computable when the tag is absent (39999::-concept-graph): only emit it when present. json tag may embed node uuids (addresses) in JSON: not modelled. b-tag-deferred marker is not a link. | +| 39999 | `AddressableListItemEvent` | z -> PARENT_LIST (E,A,T); p -> ITEM (U); e -> ITEM (E); a -> ITEM (A); t -> ITEM (T); b -> INHERIT_FROM (A); n -> ELEMENT_OF (A); s -> SUBSET_OF (A); q -> QUOTE (E,A) | parentLists() (ParentListTag), itemPubKeys()/itemEvents()/itemAddresses()/itemStrings(), inheritFrom() (InheritFromTag), elementOf() (ElementOfTag), subsetOf() (SubsetOfTag); q: new parser needed (CurationCopy only writes it) | Tapestry. q appears on assistant curation copies, pointing back to the original (address and exact version, author at q[3]); QUOTE reused, UNCERTAIN (a COPIED relation would be more precise). Taggings (TagElement, PubKeyTagging, EventTagging, TagPin) overload the item slots (e.g. PubKeyTagging: p = target, a/e = the tag applied) and are only told apart by deployment-configured z namespaces, so links() can only emit the generic ITEM; polarity/curation-method are props. Known QTag.parseAddressId bug breaks the address q. | + +### `nip90Dvms` (40) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 5000 | `DvmTextExtractionRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5000: input is a url (audio/video) or event. Quartz build() writes i[url]; reads inputs(), outputMimeType(), range/alignment params. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5001 | `DvmSummarizationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5001: example mixes i[event] and i[job]. Quartz build() writes i[event] per eventId (inputEvent). NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5002 | `DvmTranslationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5002: i[event] to translate. Quartz build() writes i[event] (inputEvent); param language. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5050 | `DvmTextGenerationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5050 uses input-type 'prompt' (not in NIP-90's list; free text, not modelled). Quartz build() writes i[prompt]; indexes prompt/text inputs for search. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5100 | `DvmImageGenerationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5100: i[text] prompt + optional i[url] source image. Quartz build() writes both (sourceImageUrl -> i[url]). NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5200 | `DvmVideoConversionRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5200: i[url] social media/video link. Quartz build() writes i[url]. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5201 | `DvmVideoTranslationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5201 example has i[url], i[event] and i[job]. Quartz build() writes i[url] only. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5202 | `DvmImageToVideoRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5202: i[url] image. Quartz build() writes i[url] (imageUrl()). NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5250 | `DvmTextToSpeechRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | Quartz build() writes i[text] (not modelled); i[event] possible per NIP-90. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5300 | `DvmContentDiscoveryRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); param[user] -> FOR_USER (U); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people); dvmPubKey() (first p only) and user() = tags.dvmParam("user"), both on the class | DVM spec kinds/5300 lists the user as a `p` param, which collides with NIP-90's `p` = service provider; Quartz (and Amethyst) put the DVM in `p` and the user in param 'user'. UNCERTAIN: other clients may put the user in `p`. relays tag (RelaysTag) not modelled. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5301 | `DvmUserDiscoveryRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); param[user] -> FOR_USER (U); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people); dvmPubKey() (first p only) and user() = tags.dvmParam("user") | DVM spec kinds/5301 is a copy of 5300 (same p-param ambiguity). build() takes only an initializer: Quartz writes nothing itself. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5302 | `DvmContentSearchRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); param[users] -> SEARCH_AUTHOR (U); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people); users() returns the raw param string: new parser needed to decode its JSON-stringified p tags | Quartz build() writes i[text] query (not modelled) and param users as an opaque string. UNCERTAIN: SEARCH_AUTHOR name; the value is a JSON array of p tags in the spec example. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5303 | `DvmPeopleSearchRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | Quartz build() writes i[text] query (not modelled) and max_results. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5400 | `DvmEventCountRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5400: inputs are tag values (i[text]); content is a NIP-01 filter JSON whose ids/authors/#e/#p are a query, not a statement: not modelled. params relay/group not modelled. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5500 | `DvmMalwareScanRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5500: i[url] file to scan. Quartz build() writes i[url] (fileUrl()). NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5900 | `DvmEventTimestampingRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5900: i[event] = event to stamp (eventIdToStamp()). INPUT, not TIMESTAMPED: the request asks for a stamp; the 1040 proof (TIMESTAMPED) comes back as the 6900's RESULT. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5901 | `DvmOpReturnRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5901: i[text] OP_RETURN payload (not modelled). NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5905 | `DvmEventPublishScheduleRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); i[text] embedded event JSON -> SCHEDULED (E); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people); eventJsons() returns the raw JSON strings: new parser needed to read each embedded event's id | DVM spec kinds/5905: request is normally encrypted (i in NIP-04 content, only p visible), so SCHEDULED is rare in public data. UNCERTAIN: SCHEDULED could be dropped since the 6905 RESULT names the same published id. params relays not modelled. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 5970 | `DvmEventPowDelegationRequestEvent` | i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); p -> SERVICE_PROVIDER (U) | tags.inputs() / InputTag.parse + firstInputByType() (nip90Dvms/tags/TagArrayExt.kt); p: new parser needed (generic tags.taggedUserIds(), nip01Core/tags/people) | DVM spec kinds/5970: i[text] is an UNSIGNED event template (no id until mined): not modelled. param pow not modelled. NIP-90 job request; every request may carry any input type and 'p' (NIP-90: 'Service Providers the customer is interested in'), so one shared DVM-request links() covers them. SERVICE_PROVIDER reused from NIP-85 (NIP-90 calls the actor 'Service providers'); UNCERTAIN: rule 4 may want it split from the NIP-85 10040 meaning. i[text]/i[prompt] are free text: not modelled. param/output/bid/relays: not modelled. With an 'encrypted' tag the i/param tags move into NIP-04 content: the graph sees only p. | +| 6000 | `DvmTextExtractionResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = extracted text (free text; Quartz parses no nostr: URIs). NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6001 | `DvmSummarizationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = summary text (free text; Quartz parses no nostr: URIs). UNCERTAIN: a summary may cite nostr: URIs; content nostr: -> MENTION would need a new parser. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6002 | `DvmTranslationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = translated text (free text; Quartz parses no nostr: URIs). NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6050 | `DvmTextGenerationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = generated text (free text; Quartz parses no nostr: URIs). NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6100 | `DvmImageGenerationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = image URL: not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6200 | `DvmVideoConversionResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = video URL: not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6201 | `DvmVideoTranslationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = video URL: not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6202 | `DvmImageToVideoResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = video URL: not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6250 | `DvmTextToSpeechResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = audio URL: not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6300 | `DvmContentDiscoveryResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content e/a tags -> RESULT (E,A) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); innerTags() (parses content as a tag array, keeps e/a values) | DVM spec kinds/5300 output: content = JSON-stringified list of e/a tags. Quartz wart: innerTags() returns List mixing event ids and address strings (Amethyst re-splits with splitInnerTags) and drops relay hints; a typed parser is needed. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6301 | `DvmUserDiscoveryResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content p tags -> RESULT (U) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); innerTags() (content tag array, p values only) | DVM spec kinds/5301 prose says output tags 'SHOULD be a or e' (copy of 5300) but its example returns p; Quartz reads p only. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6302 | `DvmContentSearchResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content e/a tags -> RESULT (E,A) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); innerTags() (content tag array, e/a values) | DVM spec kinds/5302 output: content = JSON-stringified e/a tags. Same innerTags() mixed-type wart as 6300. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6303 | `DvmPeopleSearchResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content p tags -> RESULT (U) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); innerTags() (content tag array, p values) | DVM spec kinds/5303 output: content = JSON-stringified p tags. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6400 | `DvmEventCountResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = a number or a grouped-count JSON (count()): not a reference. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6500 | `DvmMalwareScanResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = 'CLEAN' or scan report text: not a reference. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6900 | `DvmEventTimestampingResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content event id -> RESULT (E) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); otsEventId() (content) | DVM spec kinds/5900 output: content MUST be the id of the kind 1040 OTS event (which itself links TIMESTAMPED to the stamped event). Quartz does not check it is 64-hex. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6901 | `DvmOpReturnResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags) | Content = bitcoin txid (transactionId()): not a Nostr entity, not modelled. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6905 | `DvmEventPublishScheduleResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content event id -> RESULT (E) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); publishedEventId() (content) | DVM spec kinds/5905 output: 'Event ID that was published'. Spec example is copy-pasted from 5900 (says 1040 / kind 6900). Quartz does not check 64-hex. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 6970 | `DvmEventPowDelegationResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U); i[event] -> INPUT (E); i[job] -> INPUT_JOB (E); i[url] -> TAG (T); content mined event JSON -> RESULT (E) | new parser needed: the class exposes no e/p accessor; build from generic tags.taggedEventIds() / tags.taggedUserIds() (nip01Core/tags) + tags.inputs() (nip90Dvms/tags); new parser needed: the class exposes no content accessor; parse the JSON and take its id | DVM spec kinds/5970 output: 'Mined event json with nonce and calculated id'. UNCERTAIN: RESULT to an embedded event that may never be published. NIP-90 job result (kind = request + 1000). The 'request' tag embeds the stringified job request (same target as e: not a second link). 'amount' (msats [+bolt11]) could ride as prop msats on JOB_REQUEST. With 'encrypted', i is omitted and content is NIP-04 ciphertext. Amethyst already follows e (FavoriteAlgoFeedsOrchestrator filters #e = requestId). | +| 7000 | `DvmStatusEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U) | new parser needed: the class exposes only status() and firstAmount(); build from generic tags.taggedEventIds() / tags.taggedUserIds() | NIP-90 job feedback. status (code, extra-info) and amount (msats, bolt11) are props, not links: status could ride on JOB_REQUEST (prop status). Content may hold a partial result (free text; for 5300 feeds an e/a tag list could appear: UNCERTAIN). With 'encrypted' the content is NIP-04. Amethyst follows #e = requestId. | +| 11998 | `DvmHeartbeatEvent` | *none* | status(), expiration(); dTag() | Experimental DVM heartbeat (no NIP). Tags d (the DVM's NIP-89 d), status (free text), expiration: none points at another entity. Its d mirrors Address(31990, author, d), a derived link no tag states (not proposed). Replaceable-range kind on BaseAddressableEvent by design (d splits the client-side address). | + +### `nip51Lists` (32) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10000 | `MuteListEvent` | p -> MUTE (U); e -> MUTE (E); t -> MUTE (T); word -> MUTE (T) | publicMutes() / MuteTag.parse (UserTag, EventTag, HashtagTag, WordTag in nip51Lists/muteList/tags); tags.mutedUserIds(), mutedThreadIds(), mutedHashtags(), mutedWords() | NIP-51 mute list (p pubkeys, t hashtags, word lowercase strings, e threads). PRIVATE entries: yes (NIP-44 content, privateMutes()); the graph sees public tags only. t targets the same Tag(t) node HASHTAG uses; 'word' is not in the T allowlist (i/r/g/k/l/L/t): needs allowlisting as Tag(word). Quartz: implements PubKeyHintProvider but no EventHintProvider although it holds e threads. | +| 10001 | `PinListEvent` | e -> PIN (E) | pinnedEvents() / EventBookmark.parse; linkedEventIds() (EventHintProvider) | NIP-51 pinned notes (e kind 1). PRIVATE entries: no (BaseReplaceableEvent, never decrypts). Quartz reads e only; an a tag would be ignored (NIP-51 lists e only). | +| 10003 | `BookmarkListEvent` | e -> BOOKMARK (E); a -> BOOKMARK (A) | publicBookmarks() / BookmarkIdTag.parse (EventBookmark, AddressBookmark); linkedEventIds(), linkedAddressIds() | NIP-51 bookmarks (e kind 1, a kind 30023). PRIVATE entries: yes (PrivateReplaceableTagArrayEvent, privateBookmarks()). EventBookmark also reads an author pubkey hint from positions 2-4: a hint, not a link (no BOOKMARK_AUTHOR proposed). Quartz: linkedAddressIds() uses parseAddressId, which returns the raw a value unvalidated (parseValidAddress exists). | +| 10006 | `BlockedRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | NIP-51 blocked relays (relay tags). Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 10009 | `SimpleGroupListEvent` | group -> SUBSCRIBED (T) | publicGroups() / GroupTag.parse (nip51Lists/simpleGroupList) | NIP-51 simple groups: 'NIP-29 groups the user is in' (group = id + relay URL + name), filed with 10004/10005 as SUBSCRIBED. The group is not an event/address/user (its 39000 metadata is signed by an unknown relay key), so the target is Tag(group, "'" per NIP-29's identifier form): needs 'group' added to the T allowlist. UNCERTAIN: target encoding. The r tags NIP-51 also lists are relay URLs (not modelled; Quartz does not read them). PRIVATE entries: yes. Quartz: no dTag() override (see 10006). | +| 10012 | `FavoriteRelayListEvent` | a -> FAVORITE (A) | publicRelaySets() = tags.relaySetPointers() (AddressBookmark filtered to kind 30002); relay tags via publicRelays() | NIP-51 relay feeds: relay tags (not modelled) and a to kind 30002 relay sets. PRIVATE entries: yes (privateTags; no private relay-set accessor). Quartz: no dTag() override (see 10006). | +| 10015 | `InterestListEvent` | t -> SUBSCRIBED (T); a -> SUBSCRIBED (A) | publicHashtags() (HashtagTag.parse); publicInterestSets() = tags.interestSetPointers() (AddressBookmark filtered to kind 30015) | NIP-51 interests: t hashtags and a to kind 30015 interest sets (draft: SUBSCRIBED). t target is the same Tag(t) node HASHTAG uses. PRIVATE entries: yes (privateTags, privateInterestSets()). Quartz: no dTag() override (see 10006). | +| 10017 | `GitAuthorListEvent` | p -> SUBSCRIBED (U) | publicAuthors() / GitAuthorTag.parse; linkedPubKeys() (PubKeyHintProvider) | NIP-51 git authors: 'code (people who produce NIP-34 events) follow list', p with relay hint + petname like NIP-02. UNCERTAIN: the draft files 10017 under MEMBER; rule 4 (FOLLOW is kind 3, every other follow-like list is SUBSCRIBED) and 10020's identical shape argue for SUBSCRIBED. Petname could ride as a prop. PRIVATE entries: yes. Quartz: no dTag() override (see 10006). | +| 10018 | `GitRepositoryListEvent` | a -> SUBSCRIBED (A) | publicRepositories() / AddressBookmark.parse; linkedAddressIds() (AddressHintProvider) | NIP-51 git repositories: 'NIP-34 followed repositories' (a kind 30617), a follow-like list, so SUBSCRIBED (not in the draft yet; REPOSITORY is a patch's repo). PRIVATE entries: yes. Quartz: linkedAddressIds() returns unvalidated a values; no dTag() override (see 10006). | +| 10020 | `MediaFollowListEvent` | p -> SUBSCRIBED (U) | publicFollows() / UserTag.parse; linkedPubKeys() (PubKeyHintProvider) | NIP-51 media follows (draft: SUBSCRIBED). PRIVATE entries: yes (privateFollows()). Quartz: UserTag drops the NIP-02 petname; no dTag() override (see 10006). | +| 10021 | `FavoriteFollowSetsListEvent` | a -> FAVORITE (A) | publicFavoriteFollowSets() = tags.favoriteFollowSetBookmarks() (AddressBookmark filtered to kind 30000) | NIP-51 kind 10021. Not in the draft. Quartz skips a tags of other kinds. PRIVATE entries: yes (privateFavoriteFollowSets()). dTag() correctly pinned to "". No hint provider implemented. | +| 10081 | `GeohashListEvent` | g -> SUBSCRIBED (T) | publicGeohashes() = tags.geohashList() (GeoHashTag.parse, nip01Core/tags/geohash) | Followed locations (geohashes). Not in the NIP-51 table (Amethyst kind). Target is Tag(g) like TAG. PRIVATE entries: yes (decryptPrivateGeohashes()). QUARTZ BUG (privacy): the non-suspend create(publicGeohashes, privateGeohashes, NostrSignerSync) swaps them (privateTagArray = publicGeohashes, publicTagArray = privateGeohashes), so private geohashes are published in clear tags. No dTag() override (see 10006). | +| 10086 | `IndexerRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | Indexer relays (relay tags); not in the NIP-51 table. Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 10087 | `ProxyRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | Proxy relays (relay tags); not in the NIP-51 table. Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 10088 | `BroadcastRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | Broadcast relays (relay tags); not in the NIP-51 table. Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 10089 | `TrustedRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | Trusted relays (relay tags); not in the NIP-51 table. Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 10090 | `FavoriteAlgoFeedsListEvent` | a -> FAVORITE (A) | publicFavoriteAlgoFeeds() / AddressBookmark.parse; tags.favoriteAlgoFeedsList() | Not in the NIP-51 table (Amethyst kind). a points at feed DVM announcements (kind 31990); Quartz does not filter by kind. PRIVATE entries: yes (privateFavoriteAlgoFeeds()). No dTag() override (see 10006). | +| 10101 | `GoodWikiAuthorListEvent` | p -> RECOMMENDED (U) | publicAuthors() / UserTag.parse; linkedPubKeys() (PubKeyHintProvider) | NIP-51 good wiki authors: 'NIP-54 user recommended wiki authors'. UNCERTAIN: the draft files 10101 under MEMBER; NIP-51's own word is 'recommended', which reuses RECOMMENDED (extends its targets from A to U). PRIVATE entries: yes. No dTag() override (see 10006). | +| 10102 | `GoodWikiRelayListEvent` | *none* | publicRelays() / RelayTag.parse (relay URLs only) | NIP-51 good wiki relays (relay tags). Kind 10102 in Quartz; the draft's note that it claims kind 1010 does not match the current class (KIND = 10102). Relay URLs are not link targets in v1: not modelled. PRIVATE entries: yes (PrivateTagArrayEvent). Quartz: a replaceable kind on PrivateTagArrayEvent (BaseAddressableEvent) with no dTag() override, so a stray d tag splits its ADDRESS (MuteListEvent/FavoriteFollowSetsListEvent pin it to ""). | +| 30000 | `FollowSetEvent` | p -> MEMBER (U); [d=mute] p/e/t/word -> MUTE (U,E,T) | users() = tags.users() (UserTag.parse); linkedPubKeys(); publicMembers() parses MuteTag (p/e/t/word) | NIP-51 follow sets (draft: MEMBER). The deprecated d='mute' form is a mute list (NIP-51 'use instead kind 10000'), which is why Quartz parses MuteTag here: those entries should be MUTE. PRIVATE entries: yes (privateMembers()). | +| 30001 | `OldBookmarkListEvent` | e -> BOOKMARK (E); a -> BOOKMARK (A); [d=pin] e -> PIN (E); [d=communities] a -> SUBSCRIBED (A) | publicBookmarks() / BookmarkIdTag.parse; linkedEventIds(), linkedAddressIds() | Deprecated NIP-51 kind 30001 (d='bookmark' -> 10003, d='pin' -> 10001, d='communities' -> 10004). Quartz treats every 30001 as bookmarks regardless of d; the semantic method should branch on d. PRIVATE entries: yes. linkedAddressIds() unvalidated (see 10003). | +| 30002 | `RelaySetEvent` | *none* | relays() / RelayTag.parse | NIP-51 relay sets (relay tags only): not modelled. PRIVATE entries: yes. | +| 30003 | `BookmarkSetEvent` | e -> BOOKMARK (E); a -> BOOKMARK (A) | publicBookmarks() / BookmarkIdTag.parse; linkedEventIds(), linkedAddressIds() | NIP-51 bookmark sets (draft: BOOKMARK). PRIVATE entries: yes (PrivateTagArrayEvent; no privateBookmarks() accessor on this class, only privateTags()). linkedAddressIds() unvalidated (see 10003). | +| 30004 | `ArticleCurationSetEvent` | a -> CURATED (A); e -> CURATED (E) | publicItems() / BookmarkIdTag.parse; linkedEventIds(), linkedAddressIds() | NIP-51 curation set (a kind 30023, e kind 1) (draft: CURATED). PRIVATE entries: yes (PrivateTagArrayEvent). linkedAddressIds() unvalidated. | +| 30005 | `VideoCurationSetEvent` | e -> CURATED (E); a -> CURATED (A) | publicItems() / BookmarkIdTag.parse; linkedEventIds(), linkedAddressIds() | NIP-51 lists e (kind 21 videos) only; Quartz also accepts a (addressable videos). PRIVATE entries: yes. | +| 30006 | `PictureCurationSetEvent` | e -> CURATED (E); a -> CURATED (A) | publicItems() / BookmarkIdTag.parse; linkedEventIds() (EventHintProvider only) | NIP-51 lists e (kind 20 pictures) only. Quartz inconsistency: publicItems() accepts a too, but the class implements no AddressHintProvider. PRIVATE entries: yes. | +| 30007 | `KindMuteSetEvent` | p -> MUTE (U) | publicMutedUsers() / UserTag.parse; linkedPubKeys() (PubKeyHintProvider) | NIP-51 kind mute sets: 'mute pubkeys by kinds', d MUST be the kind string: the muted kind should ride as a prop (e.g. muted_kind = d) since kind stays off relation names. PRIVATE entries: yes. | +| 30015 | `InterestSetEvent` | t -> MEMBER (T) | publicHashtags() (HashtagTag.parse) | NIP-51 interest sets: 'interest topics represented by a bunch of hashtags'. A named set, so MEMBER as for follow sets (the draft names 30015 nowhere; 10015's pointer to it is SUBSCRIBED). t target is the Tag(t) node HASHTAG uses. PRIVATE entries: yes (privateHashtags()). | +| 30063 | `ReleaseArtifactSetEvent` | e -> CURATED (E); a -> APPLICATION (A); i -> TAG (T) | items() / BookmarkIdTag.parse; assets() (NIP-82 AssetTag e); appId() (NIP-82 i); linkedEventIds(), linkedAddressIds() | Kind shared by NIP-51 release artifact set and NIP-82 software release (isNip82SoftwareRelease()). e = artifacts (NIP-51 kind 1063; NIP-82 kind 3063 assets). UNCERTAIN: draft says CURATED; a release's files are not a curation, a dedicated ARTIFACT (NIP-51 'release artifact') may read better. NIP-82 i = app id (TAG); c channel / version not modelled. PRIVATE entries: no (BaseAddressableEvent, though NIP-51 content may hold them). | +| 30267 | `AppCurationSetEvent` | a -> CURATED (A) | apps() / AddressBookmark.parse; linkedAddressIds() (AddressHintProvider) | NIP-51 app curation sets (a kind 32267 software applications) (draft: CURATED). PRIVATE entries: no in Quartz (BaseAddressableEvent). | +| 39089 | `StarterPackEvent` | p -> MEMBER (U); t -> HASHTAG (T) | follows() / followIds() (UserTag.parse, nip51Lists/starterPack/TagArrayExt.kt); hashtags() (HashtagTag.parse); linkedPubKeys() | NIP-51 starter packs (draft: MEMBER). Quartz also reads t as topics. PRIVATE entries: no (BaseAddressableEvent). | +| 39092 | `MediaStarterPackEvent` | p -> MEMBER (U) | follows() / followIds() (UserTag.parse); linkedPubKeys() (PubKeyHintProvider) | NIP-51 media starter packs (draft: MEMBER). PRIVATE entries: no (BaseAddressableEvent). | + +### `nip29RelayGroups` (16) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9000 | `GroupPutUserEvent` | h -> GROUP (T\|A); p -> ADDED_USER (U) | groupId() (GroupIdTag), userPubKeys() (PTag::parseKey; roles are p[2..], read via GroupAdminTag::parse) | NIP-29 put-user, with roles riding in the p tag. The Buzz role tag is also written, as a props source. previous holds 8-char event-id prefixes, which cannot resolve to event ids and are not modelled. UNCERTAIN: PUT_USER (literal NIP word) vs ADDED_USER shared with NIP-43. | +| 9001 | `GroupRemoveUserEvent` | h -> GROUP (T\|A); p -> REMOVED_USER (U) | groupId(), userPubKeys() (PTag::parseKey) | NIP-29 remove-user. previous is not modelled (see 9000). | +| 9002 | `GroupEditMetadataEvent` | h -> GROUP (T\|A); parent -> PARENT (T\|A group); child -> CHILD (T\|A group); t -> HASHTAG (T); g -> TAG (T) | groupId(), parent() (ParentTag::parse), children() (ChildTag::parse), hashtags(), geohashes() | NIP-29 edit-metadata. The parent and child values are group ids on the same relay, so they share GROUP's target representation. previous is not modelled. | +| 9005 | `GroupDeleteEventEvent` | h -> GROUP (T\|A); e -> DELETED (E) | groupId(), deletedEventIds() (mapValueTagged('e')) | NIP-29 delete-event: a moderator deletion, which NIP-09's owner-only rule does not govern. DELETED is the NIP's action word. UNCERTAIN: a NIP-09 enforcer querying DELETED must filter on source kind 5, so rule 4 may argue for a separate MODERATOR_DELETED. deletedEventIds() does not validate 64-hex. previous is not modelled. | +| 9007 | `CreateGroupEvent` | h -> GROUP (T\|A) | groupId() (GroupIdTag::parse) | NIP-29 create-group. GROUP target proposal: NIP-29 says a group is referenced by the naddr of its kind 39000 (pubkey = relay NIP-11 self, d = id), so target Address 39000:: when the self key is known (always for a relay-side store; the 39xxx events carry it as author), else Tag('h', id) with the relay in props, or a new LinkTarget.Group(relay, id). A bare Tag('h', id) merges forks and migrations across relays, which NIP-29 says share the same id. The name, about, visibility and channel_type tags (Buzz) are not links. | +| 9008 | `DeleteGroupEvent` | h -> GROUP (T\|A) | groupId() | NIP-29 delete-group. The kind carries the action, so the h stays GROUP rather than DELETED (rule 2). | +| 9009 | `GroupCreateInviteEvent` | h -> GROUP (T\|A) | groupId() | NIP-29 create-invite. The code tag is a secret-ish value and is not modelled. | +| 9010 | `GroupUpdatePinListEvent` | h -> GROUP (T\|A); e -> PIN (E); a -> PIN (A) | groupId(), pins()/pinnedEventIds()/pinnedAddresses() (GroupPin, EventPin, AddressPin) | NIP-29 update-pin-list. It carries the full ordered list, so the order is a prop. The draft's PIN is E-only and must be widened to E, A (both 9010 and 39005 pin addresses). | +| 9021 | `GroupJoinRequestEvent` | h -> GROUP (T\|A) | groupId() | NIP-29 join request. The code (invite) is not modelled. | +| 9022 | `GroupLeaveRequestEvent` | h -> GROUP (T\|A) | groupId() | NIP-29 leave request. | +| 39000 | `GroupMetadataEvent` | parent -> PARENT (A); child -> CHILD (A); t -> HASHTAG (T); g -> TAG (T) | parent() (ParentTag::parse), children() (ChildTag::parse) -> Address 39000::, hashtags(), geohashes() | NIP-29 group metadata, signed by the relay. The group node IS this event's ADDRESS (39000::), which is the NIP's own group reference. Subgroup parent and child are on the same relay, so their addresses are exact: 39000::. t also carries the Buzz channel types (stream/forum/dm/workflow) as values, so those HASHTAGs are not topics (a quirk). | +| 39001 | `GroupAdminsEvent` | d (derived) -> GROUP (A); p -> ADMIN (U) | Address 39000::dTag() (groupId() = dTag()), admins() (GroupAdminTag::parse) | NIP-29 group admins. Relay-signed with d = group id, so the group is exactly Address 39000::. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). | +| 39002 | `GroupMembersEvent` | d (derived) -> GROUP (A); p -> MEMBER (U) | Address 39000::dTag() (groupId() = dTag()), members() (PTag::parseKey) | NIP-29 group members. It is not exhaustive (per the NIP). Relay-signed with d = group id, so the group is exactly Address 39000::. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). | +| 39003 | `GroupRolesEvent` | d (derived) -> GROUP (A) | Address 39000::dTag() (groupId() = dTag()), roles() (RoleTag::parse) has no references | NIP-29 group roles. The role names are values. Relay-signed with d = group id, so the group is exactly Address 39000::. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). | +| 39004 | `GroupParticipantsEvent` | d (derived) -> GROUP (A); participant -> PARTICIPANT (U) | Address 39000::dTag() (groupId() = dTag()), participants() (mapValueTagged('participant')) | NIP-29 LiveKit participants. participants() does not validate 64-hex. Relay-signed with d = group id, so the group is exactly Address 39000::. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). | +| 39005 | `GroupPinnedEvent` | d (derived) -> GROUP (A); e -> PIN (E); a -> PIN (A) | Address 39000::dTag() (groupId() = dTag()), pins()/pinnedEventIds()/pinnedAddresses() | NIP-29 group pinned events, ordered (order as a prop). PIN needs A added (see 9010). Relay-signed with d = group id, so the group is exactly Address 39000::. It is derived, not tagged (UNCERTAIN whether derived links belong in links()). | + +### `nip34Git` (12) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1617 | `GitPatchEvent` | a -> REPOSITORY (A); p[= repo owner] -> REPOSITORY_OWNER (U); p[other] -> MENTION (U); e[reply] -> PARENT (E); e[root] -> ROOT (E); t -> HASHTAG (T); r -> TAG (T) | repositoryAddress()/repository(), PTag::parseKey (+ compare with the a pubkey), MarkedETag parse (linkedEventIds), isRoot()/isRootRevision() (HashtagTag), earliestUniqueCommit() | NIP-34. e[reply] points at the previous patch in the series, or at the original root patch for a revision. UNCERTAIN: NIP-34 text shows only reply, but ngit also writes root markers on series. The p roles are told apart only by comparison with the a pubkey, so this needs a new parser. t values are the markers 'root' and 'root-revision'. r holds the euc and commit ids. commit, parent-commit and committer are git data, not modelled. The content is a patch, so there is no nostr: parsing. | +| 1618 | `GitPullRequestEvent` | a -> REPOSITORY (A); p[= repo owner] -> REPOSITORY_OWNER (U); p[other] -> MENTION (U); e -> REVISED (E); t -> HASHTAG (T); r -> TAG (T) | repositoryAddress(), PTag::parseKey, rootPatchId() (ETag::parseId), labels() (hashtags), earliestUniqueCommit() | NIP-34. UNCERTAIN: the placeholder is , so ROOT is an alternative, but the PR is not in that patch's thread. It supersedes it. t holds labels. c (tip commit), merge-base, branch-name and clone are not modelled. subject is not a link. | +| 1619 | `GitPullRequestUpdateEvent` | E -> ROOT (E); P -> ROOT_AUTHOR (U); a -> REPOSITORY (A); p[= repo owner] -> REPOSITORY_OWNER (U); p[other] -> MENTION (U); r -> TAG (T) | parentPullRequestId() (RootEventTag::parseKey), parentPullRequestAuthor() (RootAuthorTag::parseKey), repositoryAddress(), PTag::parseKey | NIP-34 PR update uses NIP-22 E/P for the PR, so ROOT and ROOT_AUTHOR follow the draft. c, clone and merge-base are not modelled. Quartz names the getter parentPullRequestId although the tag is the root E. The naming is fine, but note it for the golden test. | +| 1621 | `GitIssueEvent` | a -> REPOSITORY (A); p[= repo owner] -> REPOSITORY_OWNER (U); p[other] -> MENTION (U); q -> QUOTE (E,A); t -> HASHTAG (T); content nostr: -> MENTION (E,A,U) | repositoryAddress()/repository(), PTag::parseKey, QTag::parseEventId/parseAddressId, topics() (hashtags), citedNIP19() | NIP-34 issue. REPOSITORY, MENTION and QUOTE are already listed for 1621 in the draft. subject is not a link. Known QTag.parseAddressId bug. | +| 1622 | `GitReplyEvent` | a -> REPOSITORY (A); e[root] -> ROOT (E); e[reply] -> PARENT (E); p -> MENTION (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U) | repository(), rootIssueOrPatch() (MarkedETag::parseRootId), BaseThreadedEvent.reply(), PTag::parseKey, QTag, citedNIP19() | Legacy NIP-34 reply (deprecated in Quartz; NIP-34 now says to use NIP-22 kind 1111). The root is the issue or patch. It is in the draft's ROOT, PARENT and MENTION. | +| 1630 | `GitStatusOpenEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); a -> REPOSITORY (A); p[= e[root] author] -> ROOT_AUTHOR (U); p[= repo owner] -> REPOSITORY_OWNER (U); p[= e[reply] author] -> PARENT_AUTHOR (U); p[other] -> MENTION (U); r -> TAG (T) | GitStatusEvent.rootEventId()/replyEventId(), repositoryAddress(), PTag::parseKey (+ comparison with MarkedETag author and the a pubkey), referenceCommits() | NIP-34 status: e root = the issue, PR or root patch, and e reply = the accepted revision root, as in the draft. The NIP-34 p list is repository-owner, root-event-author and revision-author, with no markers. Telling them apart requires comparing each p with the e author field (Quartz writes it at position 4) and with the a pubkey. That is a new parser, and whatever cannot be resolved stays MENTION. | +| 1631 | `GitStatusAppliedEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); a -> REPOSITORY (A); p[= e[root] author] -> ROOT_AUTHOR (U); p[= repo owner] -> REPOSITORY_OWNER (U); p[= e[reply] author] -> PARENT_AUTHOR (U); p[other] -> MENTION (U); r -> TAG (T); q -> APPLIED (E) | GitStatusEvent.rootEventId()/replyEventId(), repositoryAddress(), PTag::parseKey (+ comparison with MarkedETag author and the a pubkey), referenceCommits(), appliedPatchIds() (QTag::parseEventId) | q holds the applied or merged patch ids. merge-commit and applied-as-commits are git data, not modelled (their commits also appear as r -> TAG). Quartz gap: GitStatusEvent.linkedEventIds() reads only e, so the q ids are missing from the hint provider. NIP-34 status: e root = the issue, PR or root patch, and e reply = the accepted revision root, as in the draft. The NIP-34 p list is repository-owner, root-event-author and revision-author, with no markers. Telling them apart requires comparing each p with the e author field (Quartz writes it at position 4) and with the a pubkey. That is a new parser, and whatever cannot be resolved stays MENTION. | +| 1632 | `GitStatusClosedEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); a -> REPOSITORY (A); p[= e[root] author] -> ROOT_AUTHOR (U); p[= repo owner] -> REPOSITORY_OWNER (U); p[= e[reply] author] -> PARENT_AUTHOR (U); p[other] -> MENTION (U); r -> TAG (T) | GitStatusEvent.rootEventId()/replyEventId(), repositoryAddress(), PTag::parseKey (+ comparison with MarkedETag author and the a pubkey), referenceCommits() | NIP-34 status: e root = the issue, PR or root patch, and e reply = the accepted revision root, as in the draft. The NIP-34 p list is repository-owner, root-event-author and revision-author, with no markers. Telling them apart requires comparing each p with the e author field (Quartz writes it at position 4) and with the a pubkey. That is a new parser, and whatever cannot be resolved stays MENTION. | +| 1633 | `GitStatusDraftEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); a -> REPOSITORY (A); p[= e[root] author] -> ROOT_AUTHOR (U); p[= repo owner] -> REPOSITORY_OWNER (U); p[= e[reply] author] -> PARENT_AUTHOR (U); p[other] -> MENTION (U); r -> TAG (T) | GitStatusEvent.rootEventId()/replyEventId(), repositoryAddress(), PTag::parseKey (+ comparison with MarkedETag author and the a pubkey), referenceCommits() | NIP-34 status: e root = the issue, PR or root patch, and e reply = the accepted revision root, as in the draft. The NIP-34 p list is repository-owner, root-event-author and revision-author, with no markers. Telling them apart requires comparing each p with the e author field (Quartz writes it at position 4) and with the a pubkey. That is a new parser, and whatever cannot be resolved stays MENTION. | +| 10317 | `UserGraspListEvent` | *none* | – | NIP-34 grasp list. The g tags here are grasp SERVER URLs, not geohashes, and servers are not modelled. Cross-cutting trap: a generic g -> TAG (geohash) rule would mislabel these, so g must be read per kind. | +| 30617 | `GitRepositoryEvent` | maintainers -> MAINTAINER (U); t -> HASHTAG (T); r[euc] -> TAG (T); u -> FORK (A) | maintainers() (MaintainersTag::parse), hashtags(), earliestUniqueCommit() (EucTag::parse); new parser needed for u | NIP-34. u ('30617::\|', 'indicate repository is a subordinate fork') is not parsed by Quartz. It is FORK (A) when the value is an address, and not modelled when it is a git URL. This extends the draft's FORK from E to E, A. t includes the 'personal-fork' marker. web, clone and relays are not modelled. | +| 30618 | `GitRepositoryStateEvent` | d (derived) -> REPOSITORY (A) | new parser needed (Address 30617::) | NIP-34: 'd matches the identifier in the corresponding repository announcement', so the repository address is derived from the author plus d, much as the 39xxx GROUP link is. The refs and HEAD are git data, not modelled. UNCERTAIN: whether derived links belong in links() or in the graph layer. | + +### `marmot` (8) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 444 | `WelcomeEvent` | e -> KEY_PACKAGE (E); h -> GROUP (T) | keyPackageEventId() (KeyPackageEventTag::parse), nostrGroupId() | Marmot MIP-02. This is an unsigned rumor inside a NIP-59 gift wrap (1059 -> 13 -> 444), so a relay-side graph never sees it. Links apply only after unwrapping. h is the Marmot nostr_group_id (random 32-byte hex, not relay-scoped, unlike NIP-29), so Tag('h', id) is a sound target here. UNCERTAIN: whether Marmot and NIP-29 should share GROUP or get separate relations. relays is not modelled. Quartz reads h inline in nostrGroupId() instead of mip03 GroupIdTag (minor). | +| 445 | `GroupEvent` | h -> GROUP (T) | groupId() (mip03 GroupIdTag::parse) | Marmot MIP-03. The pubkey is ephemeral per event, so AUTHOR is meaningless here: flag it so the graph does not grow one throwaway User per message. The content is encrypted MLS, and the inner kind 9/7 rumors have their own links. The target is Tag('h', nostr_group_id), a global random id. | +| 446 | `NotificationRequestEvent` | *none* | – | Marmot MIP-05 trigger. It has only a v (version) tag and an ephemeral pubkey. The content is encrypted token chunks addressed to a notification server via gift wrap, so there is nothing to link. | +| 447 | `TokenRequestEvent` | content entries[member_id] -> MEMBER (U); content entries[server_pubkey] -> NOTIFICATION_SERVER (U) | new parser needed (entries() = PushGossip.decodeTokens -> PushTokenEntry.memberIdHex/serverPubKeyHex) | Marmot MIP-05. This is an unsigned inner app payload inside kind 445. It is never relay-visible and is readable only by group members. UNCERTAIN whether to model it at all. Otherwise it would be status none in practice. For a self-update, member_id is the sender. Empty content is a request and has no links. | +| 448 | `TokenListEvent` | content entries[member_id] -> MEMBER (U); content entries[server_pubkey] -> NOTIFICATION_SERVER (U) | new parser needed (entries() = PushGossip.decodeTokens) | Marmot MIP-05. This is an unsigned inner payload, never relay-visible (UNCERTAIN, as for 447). The entries include OTHER members' records relayed with their owner_sig, so member_id is not the sender. | +| 449 | `TokenRemovalEvent` | content entries[member_id] -> MEMBER (U); content entries[server_pubkey] -> NOTIFICATION_SERVER (U) | new parser needed (entries() = PushGossip.decodeRemovals) | Marmot MIP-05 removal (tombstones). This is an unsigned inner payload, never relay-visible (UNCERTAIN, as for 447). A dedicated REMOVED relation would be overkill for a payload no graph sees. | +| 10051 | `KeyPackageRelayListEvent` | *none* | – | Marmot MIP-00 KeyPackage relay list. It holds only relay tags, which are not modelled. | +| 30443 | `KeyPackageEvent` | i -> TAG (T) | keyPackageRef() (KeyPackageRefTag, tag 'i') | Marmot MIP-00. i holds the KeyPackageRef hex (a lookup key). The MLS parameter tags, client and the relays list are not modelled. | + +### `nip53LiveActivities` (8) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1311 | `LiveActivitiesChatMessageEvent` | a[1st, root marker optional] -> ROOT (A); e -> PARENT (E); p -> MENTION (U); q -> QUOTE (E,A); t -> HASHTAG (T); content nostr: -> MENTION (E,A,U) | activity()/activityAddress() (ATag::parse), BaseThreadedEvent.reply(), PTag::parseKey, QTag::parseEventId/parseAddressId, tags.hashtags(), citedNIP19() | NIP-53: the activity a is the ROOT (the spec example uses the 'root' marker; the text says just 'a'), and e is the direct parent. The activity can be a 30311 or, via roomMessage(), a 30312 space. Bug: unmarkedReplyTos() calls super.markedReplyTos() (copy-paste). Known QTag.parseAddressId bug. | +| 1312 | `LiveActivitiesRaidEvent` | a[root] -> ROOT (A); a[mention] -> RAIDED (A) | fromActivity()/fromAddress(), toActivity()/toAddress() | zap.stream convention, not in NIP-53 master. The root marker is the source stream (the one raiding), so the marker gives ROOT. The mention marker is the target. Rule 7 ('marker wins') would say MENTION, but the draft already chose RAIDED because the target IS the statement. Flag this tension in rule 7. Both are filtered to kind 30311. | +| 1313 | `LiveActivitiesClipEvent` | a -> CLIPPED (A); p -> CLIPPED_AUTHOR (U); r -> TAG (T) | activity()/activityAddress(), host() (PTag::parseKey), videoUrl() (ReferenceTag::parse) | zap.stream convention, not in NIP-53 master. The p is the stream host, which is not necessarily the 30311 signer (a provider may sign), hence CLIPPED_AUTHOR rather than the address AUTHOR. r is the playable video URL. | +| 10112 | `NestsServersEvent` | *none* | – | Nests audio-room server list (server/relay URLs plus auth URLs). The servers are not modelled. | +| 10312 | `MeetingRoomPresenceEvent` | a[root] -> ROOT (A) | interactiveRoom()/linkedAddressIds() (MeetingSpaceTag::parse / parseAddressId) | NIP-53 room presence: ['a', , relay, 'root'], with the ROOT as in the draft. hand, muted, publishing and onstage are flags, not links. Bug: MeetingSpaceTag.assemble writes ['a', addr, relay] WITHOUT the 'root' marker the NIP requires, so the parser must accept an unmarked a. build(root: MeetingRoomEvent) points the presence at a 30313 meeting, while the spec says the room (30312). Both occur. | +| 30311 | `LiveActivitiesEvent` | p -> PARTICIPANT (U); pinned -> PIN (E); goal -> GOAL (E) | participants() (ParticipantTag::parse), pinned() (PinnedEventTag::parse), goalEventId() | NIP-53. Props on PARTICIPANT: role (Host/Speaker/Participant) and proof. UNCERTAIN: rule 4 may justify HOST as its own relation, since the signer is often a provider and the Host p is the actual streamer ('streams by X' queries). t is in the spec but not read by Quartz, so it is not listed. streaming, recording and relays URLs are not modelled. | +| 30312 | `MeetingSpaceEvent` | p -> PARTICIPANT (U) | participants() (ParticipantTag::parse) | NIP-53 space. The p entries are providers with roles (Host/Moderator/Speaker), carried as props. t is in the spec but not read by Quartz. The service, endpoint and relays URLs are not modelled. Style: inline fully-qualified tag names in the class body. | +| 30313 | `MeetingRoomEvent` | a -> PARENT (A); p -> PARTICIPANT (U); pinned -> PIN (E) | interactiveRoom() (MeetingSpaceTag::parse), participants(), pinned() | NIP-53 meeting: the a is the parent space (30312), with PARENT as in the draft. pinned is not in NIP-53 for 30313, but Quartz reads it (the draft lists it). | + +### `nip43RelayMembers` (7) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 8000 | `RelayAddMemberEvent` | p -> ADDED_USER (U) | memberPubKeys() (PTag::parseKey) | NIP-43. Signed by the relay NIP-11 self key. UNCERTAIN: whether to unify with NIP-29 9000 or give that its own PUT_USER, since put-user also re-puts roles of existing members. | +| 8001 | `RelayRemoveMemberEvent` | p -> REMOVED_USER (U) | memberPubKeys() (PTag::parseKey) | NIP-43. Signed by the relay self key. | +| 13534 | `RelayMembershipListEvent` | member -> MEMBER (U) | membersWithRoles() (MemberTag::parseMember) | NIP-43 membership list, signed by the relay self key. Props: roles, which are the d-tags of 33534 role events and resolvable to Address 33534:: if roles ever become links. | +| 28934 | `RelayJoinRequestEvent` | *none* | – | NIP-43 join request. Its only data is the claim (invite code), which is not modelled. Ephemeral. | +| 28935 | `RelayInviteRequestEvent` | *none* | – | NIP-43 invite request. It has no tags beyond the initializer. Ephemeral. | +| 28936 | `RelayLeaveRequestEvent` | *none* | – | NIP-43 leave request. It carries only the NIP-70 '-' tag. Ephemeral. | +| 33534 | `RelayRoleEvent` | *none* | – | NIP-43 role definition: d = role id, with label, description, color and order. There are no references. | + +### `nip64Chess` (7) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 30 | `JesterEvent` | e[1st, move events] -> ROOT (E); e[2nd, move events] -> PARENT (E); p -> OPPONENT (U) | startEventId(), headEventId(), opponentPubkey() | Jester protocol (jesterui FLOW.md), not NIP-64. ROOT/PARENT: Jester links moves as [startId, headId], which is the game thread's root and the previous move. On START events (content.kind=0) the single e is JesterProtocol.START_POSITION_HASH, a sha256 of the start FEN rather than an event id. It is not modelled, and a shape-based rule would make it a phantom Event node that every Jester game links to. Quartz quirk: startEventId() returns that hash for start events. The link needs the content kind, so it needs a content parse. p is set only for private challenges and moves. | +| 64 | `ChessGameEvent` | *none* | – | NIP-64: PGN in content, and only alt as a tag. The White/Black PGN headers are free-text names, not pubkeys. | +| 30064 | `LiveChessGameChallengeEvent` | p -> OPPONENT (U) | opponentPubkey() (OpponentTag::parseKey) | Amethyst-only live chess kind (docs/live-chess-implementation-status.md), not NIP-64. With no p, it is an open challenge. d = gameId, a value. | +| 30065 | `LiveChessGameAcceptEvent` | e -> ACCEPTED (E); p -> OPPONENT (U) | challengeEventId() (ChallengeEventTag::parse), opponentPubkey() | Amethyst-only kind. The p is the challenger. The game itself (the challenge address 30064::) is derivable only from the e, whose tag[3] author Quartz writes. | +| 30066 | `LiveChessMoveEvent` | p -> OPPONENT (U) | opponentPubkey() | Amethyst-only kind. game_id and d (gameId-moveN) are values. The game is 30064::, but the challenger may be the author or the opponent, so it is not derivable from the event alone. A GAME (A) relation would need the challenge in hand (UNCERTAIN, not proposed). | +| 30067 | `LiveChessGameEndEvent` | p -> OPPONENT (U); winner -> WINNER (U) | opponentPubkey(), winnerPubkey() (WinnerTag::parse) | Amethyst-only kind. Props: result and termination. Bug-ish: WinnerTag.parse accepts any non-empty string (no 64-hex check), so it needs validation before becoming a User link. | +| 30068 | `LiveChessDrawOfferEvent` | p -> OPPONENT (U) | opponentPubkey() | Amethyst-only kind. d = gameId. | + +### `nip15Marketplace` (6) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1021 | `BidEvent` | e -> AUCTION (E); p -> AUCTION_AUTHOR (U) | auctionId() (ETag::parseId), PTag::parseKey | NIP-15: ['e', ], with content = amount (props: amount). The p is Quartz's addition, not in NIP-15. The auction is a 30020 addressable event referenced by id, so the target is E. | +| 1022 | `BidConfirmationEvent` | e[1st] -> BID (E); e[2nd] -> AUCTION (E); p -> BID_AUTHOR (U) | new parser needed (positional e; Quartz only has linkedEventIds() = all ETag ids), PTag::parseKey | NIP-15: [['e', ], ['e', ]], order-defined. Quartz writes bid then auction but exposes no bidId()/auctionId(). Props: status (accepted/rejected/pending/winner) and duration_extension from content. The p is Quartz's addition. | +| 30017 | `StallEvent` | *none* | – | NIP-15 stall: d plus content JSON (name, currency, shipping). There are no references. | +| 30018 | `ProductEvent` | content stall_id -> STALL (A); t -> HASHTAG (T) | new parser needed for STALL (productData().stallId -> Address 30017::), categories() (hashtags) | NIP-15. This is a content reference, not a tag. t holds categories. | +| 30019 | `MarketplaceEvent` | content merchants[] -> MERCHANT (U) | new parser needed (marketplaceData().merchants) | NIP-15 marketplace UI/UX. This is a content reference. MERCHANT follows rule 7's list naming; MEMBER is the alternative (UNCERTAIN). | +| 30020 | `AuctionEvent` | content stall_id -> STALL (A); t -> HASHTAG (T) | new parser needed (auctionData().stallId), tags.hashtags() | NIP-15 auction. Bids reference it by EVENT id (1021/1022), not by address. | + +### `nip28PublicChat` (6) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 40 | `ChannelCreateEvent` | a -> MENTION (A) | ATag::parseAddressId (via linkedAddressIds) | NIP-28 defines no tags on kind 40 (metadata and relays live in content JSON; relays are not modelled). UNCERTAIN: Quartz reads arbitrary a tags as address hints, and nothing gives them a meaning. They could also be dropped. Known bug: linkedEventIds() returns the event's own id. The status is effectively none, except for the stray a tags. | +| 41 | `ChannelMetadataEvent` | e[root] -> ROOT (E) | BasePublicChatEvent.channel()/channelId() (MarkedETag.parseRoot ?: parseUnmarkedRoot) | NIP-28: ['e', , relay, 'root']. The channel is the ROOT, as the draft already decides. NIP-28 also allows t (categories) on 41, which Quartz never reads or writes, so it is not listed. If t is later read, t -> HASHTAG (T). | +| 42 | `ChannelMessageEvent` | e[root] -> ROOT (E); e[reply] -> PARENT (E); p -> MENTION (U); q -> QUOTE (E,A); a -> MENTION (A); content nostr: -> MENTION (E,A,U) | channel()/channelId(), BaseThreadedEvent.reply()/markedReply(), PTag::parseKey, QTag::parseEventId/parseAddressId, ATag::parseAddressId, citedNIP19() | NIP-28 root is the channel and reply is the parent message. The NIP-28 reply example has a p for the replied-to author. Quartz writes it via notify() as a plain p, so it is MENTION per the draft. It could be PARENT_AUTHOR, but no marker distinguishes it (UNCERTAIN). markedReplyTos/unmarkedReplyTos already strip the channel id. Known bug: QTag.parseAddressId rejects every address, so q addresses are lost until it is fixed. | +| 43 | `ChannelHideMessageEvent` | e[root] -> ROOT (E); e[unmarked] -> HIDDEN (E) | channel() (MarkedETag.parseRoot), ETag::parseId for the hidden ids (must exclude the root) | NIP-28 kind 43 carries only ['e', ]. Quartz ALSO writes the channel as a root-marked e. Bugs: (1) eventsToHide() = taggedEventIds() includes the channel root id, so the channel is 'hidden' too. (2) On a spec-conformant 43 (no root), channel() falls back to parseUnmarkedRoot and returns the HIDDEN MESSAGE as the channel. The semantic method must split root from unmarked. | +| 44 | `ChannelMuteUserEvent` | e[root] -> ROOT (E); p -> CHANNEL_MUTED (U) | channel() (MarkedETag.parseRoot), usersToMute() (PTag::parseKey) | NIP-28 kind 44 carries only ['p', pubkey]. The channel root e is Quartz's addition (see 43). CHANNEL_MUTED is already in the draft. | +| 10005 | `PublicChatListEvent` | e -> SUBSCRIBED (E) | channels() (ChannelTag::parse), linkedEventIds() (ChannelTag::parseId) | NIP-51 public chats list, pointing at NIP-28 kind 40 channels. This matches the draft (SUBSCRIBED lists 10005). Private entries are NIP-44 encrypted in content, visible only to the owner, and not linked. ChannelTag reads an optional author at position 2-4, a candidate for props. | + +### `nipACWebRtcCalls` (6) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 25050 | `CallOfferEvent` | p -> RECIPIENT (U) | recipientPubKeys() (PTag::parseKey) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. | +| 25051 | `CallAnswerEvent` | p -> RECIPIENT (U) | recipientPubKeys() (PTag::parseKey) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. | +| 25052 | `CallIceCandidateEvent` | p -> RECIPIENT (U) | PTag::parseKey (no recipientPubKeys() accessor on this class) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. In group calls ICE candidates carry only the peer. | +| 25053 | `CallHangupEvent` | p -> RECIPIENT (U) | recipientPubKeys() (PTag::parseKey) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. | +| 25054 | `CallRejectEvent` | p -> RECIPIENT (U) | recipientPubKeys() (PTag::parseKey) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. | +| 25055 | `CallRenegotiateEvent` | p -> RECIPIENT (U) | recipientPubKeys() (PTag::parseKey) | NIP-AC (in-repo NIP-AC.md): 'p: Hex pubkey of the recipient (group calls: one per member)'. Ephemeral kind, delivered inside an ephemeral gift wrap 21059, so a relay graph never sees it plainly. call-id and call-type are session values, not modelled. | + +### `concord` (5) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 3302 | `ConcordChatEditEvent` | e -> EDITED (E) | editedMessageId() (firstTaggedEvent) | Concord CORD-02 Appendix B edit rumor. channel / epoch / ms binding tags carry Concord-internal channel ids, not Nostr entities: not modelled. Draft row verified. | +| 3308 | `ControlEditionEvent` | *none* | vsk()/eid()/ev()/ep()/vac() (concord/cord04Roles/control/tags) | Concord CORD-02/04 control-plane edition. eid (entity id), ep (prev edition hash), vac (grant id/version/hash) are Concord entity ids/hashes, not Nostr event ids/addresses/pubkeys; content is entity JSON. No Nostr links. | +| 13302 | `ConcordCommunityListEvent` | *none* | decrypt()/decryptDocument() (NIP-44 self-encrypted content, no tags) | Concord CORD-05 joined-communities list; everything (community roots, keys) is in encrypted content; built with emptyArray() tags. | +| 13303 | `ConcordInviteListEvent` | *none* | decrypt() (NIP-44 self-encrypted content, no tags) | Concord CORD-05 invite list; tokens and link-signer keys encrypted; no tags. | +| 33301 | `ConcordInviteBundleEvent` | *none* | versionedSubKind() (VskTag); content NIP-44 encrypted under the link token | Concord CORD-05 invite bundle: d='' and vsk only; no Nostr references visible. | + +### `nip71Video` (5) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 21 | `VideoNormalEvent` | p -> PARTICIPANT (U) [props role=label when present]; p[mention]/a[mention]/e[mention] -> MENTION (U,A,E); p[inspired-by]/a[label]/e[label e.g. audio] -> CREDITED (U,A,E) [props credit=label]; text-track[nevent/address ref] -> TEXT_TRACK (E,A); t -> HASHTAG (T) | participants() (PTag::parse), credits() (VideoCredits.parse: p/a/e with marker labels), textTrack() (TextTrackTag::parse; ref untyped, needs Address/NIP-19 detection), hashtags() | NIP-71: p = 'participant in the video'; text-track = 'link to WebVTT file' but example uses an encoded event and divine.video writes a 39307 address; r (web refs) is in the spec but Quartz does not read it. Credits labels are divine.video convention, not NIP-71. UNCERTAIN: whether role labels like 'Collaborator' stay PARTICIPANT(props role) or become CREDITED. Quartz gap: video classes implement no Event/PubKey/Address hint provider although they carry p/a/e. DRAFT FIX: draft lists 34238 (video collaboration) as REFERENCE-only; the collaborator p/credit convention here overlaps it. DRAFT FIX: PARTICIPANT kinds should add 21, 22, 34235, 34236. | +| 22 | `VideoShortEvent` | p -> PARTICIPANT (U) [props role=label when present]; p[mention]/a[mention]/e[mention] -> MENTION (U,A,E); p[inspired-by]/a[label]/e[label e.g. audio] -> CREDITED (U,A,E) [props credit=label]; text-track[nevent/address ref] -> TEXT_TRACK (E,A); t -> HASHTAG (T) | participants() (PTag::parse), credits() (VideoCredits.parse: p/a/e with marker labels), textTrack() (TextTrackTag::parse; ref untyped, needs Address/NIP-19 detection), hashtags() | Same tags as kind 21 (RegularVideoEvent). NIP-71. | +| 34235 | `AddressableNormalVideoEvent` | p -> PARTICIPANT (U) [props role=label when present]; p[mention]/a[mention]/e[mention] -> MENTION (U,A,E); p[inspired-by]/a[label]/e[label e.g. audio] -> CREDITED (U,A,E) [props credit=label]; text-track[nevent/address ref] -> TEXT_TRACK (E,A); t -> HASHTAG (T) | participants() (PTag::parse), credits() (VideoCredits.parse: p/a/e with marker labels), textTrack() (TextTrackTag::parse; ref untyped, needs Address/NIP-19 detection), hashtags() | Same tags as kind 21 (AddressableVideoEvent). NIP-71 addressable video. | +| 34236 | `AddressableShortVideoEvent` | p -> PARTICIPANT (U) [props role=label when present]; p[mention]/a[mention]/e[mention] -> MENTION (U,A,E); p[inspired-by]/a[label]/e[label e.g. audio] -> CREDITED (U,A,E) [props credit=label]; text-track[nevent/address ref] -> TEXT_TRACK (E,A); t -> HASHTAG (T) | participants() (PTag::parse), credits() (VideoCredits.parse: p/a/e with marker labels), textTrack() (TextTrackTag::parse; ref untyped, needs Address/NIP-19 detection), hashtags() | Same tags as kind 21 (AddressableVideoEvent). NIP-71 addressable short video. | +| 39307 | `TextTrackEvent` | a -> VIDEO (A); l -> TAG (T) | video() (ATag::parseAddress), language() (LanguageTag, l) | divine.video convention (not in NIP-71): addressable timed-text track referenced from a video's text-track tag. url is the hosted WebVTT (not modelled). | + +### `nip85TrustedAssertions` (5) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10040 | `TrustProviderListEvent` | slot 1 pubkey -> SERVICE_PROVIDER (U) [props service] | serviceProviders() (ServiceProviderTag::parse) | NIP-85 'Declaring Trusted Service Providers'. Draft SERVICE_PROVIDER row confirmed (one link per entry). Encrypted entries in content are invisible to the graph. | +| 30382 | `UserAssertionEvent` | d -> SUBJECT (U) [props rank, followers, hops, ...]; t -> HASHTAG (T) | aboutUser() (dTag), rank()/followerCount()/hops()/... for props, topics() (TopicTag, t) | NIP-85 kind 30382. Draft SUBJECT row confirmed. p with the same value as d is only a relay hint (no extra link). Encrypted contact-card fields (petname/summary) not modelled. | +| 30383 | `EventAssertionEvent` | d -> SUBJECT (E) [props rank, comment_cnt, ...] | aboutEvent() (dTag), rank()/commentCount()/... for props | NIP-85 kind 30383. Draft SUBJECT row confirmed; e equal to d is a relay hint only. | +| 30384 | `AddressableAssertionEvent` | d -> SUBJECT (A) [props rank, comment_cnt, ...] | aboutAddress() (dTag), rank()/... for props | NIP-85 kind 30384. Draft SUBJECT row confirmed; a equal to d is a relay hint only. | +| 30385 | `ExternalIdAssertionEvent` | d -> SUBJECT (T) [NIP-73 id; props rank, comment_cnt, reaction_cnt]; k -> TAG (T) | aboutExternalId() (dTag), rank()/commentCount()/reactionCount(); k: KindTag (not read by the class) | NIP-85 kind 30385 'NIP-73 identifier' subject; 'NIP-73 k tags should be added'. DRAFT FIX: SUBJECT row lists only 30382-30384 and targets U,E,A; add 30385 and target T (Tag name i). | + +### `cyberspace` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 3330 | `SnoShardEvent` | content palette (nevent/naddr) -> PALETTE (E,A) | SnoParser.parse -> SnoPayload.paletteRef (SnoPaletteRef.Event bech32) via readPaletteRef | Cyberspace DECK-0003 §3.2 bag item. Usually sealed (blank content). C coordinate tag is a cyberspace coordinate: not modelled. Pinned to the named event (reader MUST NOT follow forward) - the E target matters for nevent. | +| 11333 | `SnoAvatarEvent` | content palette (nevent/naddr) -> PALETTE (E,A) | SnoParser.parse -> SnoPaletteRef.Event | Cyberspace v2 §8.10 avatar (replaceable). name tag and nonce/PoW: values, not modelled. Blank content = default avatar (no link). | +| 33330 | `CyberspaceBagEvent` | *none* | lookupId() (d), height() (h), hint(), payload() (encrypted tag) | Cyberspace v2 §7.6 bag: items are AES-GCM encrypted inside the `encrypted` tag; d = region lookup id, h = height, version: values. Items once opened are their own events (not links). No Nostr references in cleartext. | +| 33331 | `SnoObjectEvent` | content palette (nevent/naddr) -> PALETTE (E,A) | SnoParser.parse -> SnoPaletteRef.Event | Cyberspace DECK-0003 §3.1 standalone object; name tag is a value. | + +### `nip47WalletConnect` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 13194 | `NwcInfoEvent` | *none* | capabilities() (content), EncryptionTag/NotificationsTag/ExtensionsTag | NIP-47 info event: capabilities, encryption schemes, notification types; no references. | +| 23194 | `NwcRequestEvent` | p -> RECIPIENT (U) | walletServicePubKey() (first p) | NIP-47: p = 'the public key of the wallet service'. Chose RECIPIENT (the addressee an encrypted message is p-tagged and encrypted to, rule 2) over the NIP's role word. UNCERTAIN: alt WALLET_SERVICE (U) if wallet-service graphs are wanted. Ephemeral kind - rarely stored. | +| 23195 | `NwcResponseEvent` | e -> REQUEST (E); p -> REQUEST_AUTHOR (U) | requestId() (first e), requestAuthor() (first p) | NIP-47. UNCERTAIN: p could equally be RECIPIENT (it is the encryption addressee); REQUEST_AUTHOR chosen because it is always the request's author and matches rule 3. Ephemeral kind. | +| 23197 | `NwcNotificationEvent` | p -> RECIPIENT (U) | clientPubKey() (first p) | NIP-47 notification (legacy 23196 same shape): p = client pubkey, encrypted to it. Ephemeral. | + +### `nip52Calendar` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 31922 | `CalendarDateSlotEvent` | p -> PARTICIPANT (U); a -> CALENDAR (A); t -> HASHTAG (T); g -> TAG (T); r -> TAG (T) | participants() (PTag), hashtags(), geohash(), references(); a -> new parser needed | NIP-52. Props on PARTICIPANT: role (p slot 3). location/start/end values. Draft PARTICIPANT row verified; DRAFT FIX: add CALENDAR for the inclusion-request a. | +| 31923 | `CalendarTimeSlotEvent` | p -> PARTICIPANT (U); a -> CALENDAR (A); t -> HASHTAG (T); g -> TAG (T); r -> TAG (T) | participants() (PTag), hashtags(), geohash(), references(); a -> new parser needed | NIP-52. D day-index, start/end/tzid values. Props role on PARTICIPANT. | +| 31924 | `CalendarCollectionEvent` | a -> MEMBER (A) | calendarEventAddresses() (taggedAddresses) / ATag::parseAddressId | NIP-52 calendar: a = 31922/31923 events it includes. Draft MEMBER row verified. | +| 31925 | `CalendarRSVPEvent` | a -> CALENDAR_EVENT (A); e -> CALENDAR_EVENT (E); p -> CALENDAR_EVENT_AUTHOR (U) | calendarEventAddress() (firstTaggedAddress), calendarEventId() (firstTaggedEvent), calendarEventAuthor() (PTag) | NIP-52 RSVP. Props status (accepted/declined/tentative) and fb. Draft CALENDAR_EVENT row verified; DRAFT FIX: add the author relation. | + +### `nip54Wiki` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 818 | `WikiMergeRequestEvent` | a -> DESTINATION (A); p -> DESTINATION_AUTHOR (U); e[source] -> SOURCE (E); unmarked e -> BASE_VERSION (E) | targetArticle() (ATag::parseAddress), destinationAuthor() (PTag::parseKey), mergeSource() (e with source\|fork marker), baseVersion() (unmarked e) | NIP-54 Merge Requests. DRAFT FIX: remove 818 from the REFERENCE-until-classified list. Quartz also accepts `fork` as the source marker. UNCERTAIN: SOURCE is a very generic name in a cross-kind vocabulary (alt: MERGE_SOURCE); BASE_VERSION alt: BASED_ON. | +| 819 | `WikiMergeAcceptanceEvent` | e[result] -> RESULT (E); e[request] -> REQUEST (E); p -> REQUEST_AUTHOR (U) | result()/request() (markedEvent by marker), requester() (PTag::parseKey) | Kind 819 is NOT in NIP-54 on nostr-protocol/nips master (NIP-54 says the destination accepts/rejects via NIP-25 reactions to the 818); Quartz-only / proposal. UNCERTAIN until specified. DRAFT FIX: remove 819 from the REFERENCE list. | +| 30818 | `WikiArticleEvent` | a[fork] -> FORK (A); e[fork] -> FORK (E); a[defer] -> DEFER (A); e[defer] -> DEFER (E); other a/e -> MENTION (E,A); p -> MENTION (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U); t -> HASHTAG (T) | forkFromAddress() (ForkTag::parseAddress), forkFromVersion() (MarkedETag::parseForkedEventId), ATag/MarkedETag/PTag/QTag, citedNIP19(); defer -> new parser needed | NIP-54. DRAFT FIX: draft lists 30818 under PARENT, but NIP-54 defines no parent/reply for articles - its a/e are fork (and defer) references; move 30818 to FORK (and FORK needs A and E targets). Content is Asciidoc/Markdown with wikilinks to d-tags ([[...]]), which are slugs, not addresses: not modelled. Known: QTag.parseAddressId rejects every address. | +| 30819 | `WikiRedirectEvent` | a -> REDIRECT (A) | target() (ATag::parseAddress) | NIP-54 redirects; d = normalized from-slug. Draft row verified. | + +### `nip58Badges` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 8 | `BadgeAwardEvent` | a -> BADGE_DEFINITION (A); p -> AWARDED (U) | awardDefinition() (taggedAddresses), awardeeIds() (taggedUserIds) | NIP-58: single a (30009) + one p per awardee. Hint providers also read e tags, which NIP-58 does not define for kind 8 (ignore). Draft rows verified. | +| 10008 | `ProfileBadgesEvent` | a (paired) -> BADGE_DEFINITION (A); e (paired) -> BADGE_AWARD (E); a to kind 30008 -> BADGE_SET (A) | acceptedBadges() (AcceptedBadge.parseAll pairs); badgeAwardDefinitions() (taggedAddresses); BADGE_SET split -> new parser needed | NIP-58 (10008 is a NIP-51 standard list). Quartz: badgeAwardDefinitions() returns every a tag, so a 30008 badge-set pointer reads as a badge definition (bug for the relation). Hint providers read p tags NIP-58 does not define here. | +| 30008 | `AcceptedBadgeSetEvent` | a (paired) -> BADGE_DEFINITION (A); e (paired) -> BADGE_AWARD (E) | acceptedBadges() (AcceptedBadge.parseAll), badgeAwardEvents(), badgeAwardDefinitions() | NIP-58 Badge Set (NIP-51 set); d=profile_badges is the legacy profile-badges form (treat as 10008). title/image/description values. Draft rows verified. | +| 30009 | `BadgeDefinitionEvent` | *none* | badgeName/badgeImage/badgeThumbs/badgeDescription | NIP-58 badge definition: name, image and thumb URLs only. (ADDRESS/AUTHOR only.) | + +### `nip60Cashu` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 7374 | `CashuMintQuoteEvent` | *none* | – | NIP-60 quote: content is the encrypted quote id; tags expiration and mint URL (not modelled). | +| 7375 | `CashuTokenEvent` | *none* | – | NIP-60 token: everything (mint, proofs, del token ids) is NIP-44 encrypted in content; no public tags. The encrypted del list would be DESTROYED links but is invisible to the graph. | +| 7376 | `CashuSpendingHistoryEvent` | e[redeemed] -> REDEEMED (E); p -> REDEEMED_AUTHOR (U); e[created] -> CREATED (E); e[destroyed] -> DESTROYED (E) | redeemedNutzaps() / redeemedReferences() (TokenReference::parseFromTag), PTag::parseKey; created/destroyed: TokenReference on public tags (usually encrypted) | NIP-60 says created/destroyed e tags SHOULD be encrypted and only redeemed stays public, so CREATED/DESTROYED are rare in the graph (encrypted tags never reach it). UNCERTAIN: p could instead reuse ZAP_SENDER (NIP-61 calls it the 'nutzap sender'), but rule 3 favours REDEEMED_AUTHOR. | +| 17375 | `CashuWalletEvent` | *none* | – | NIP-60 wallet: privkey and mint tags are NIP-44 encrypted in content; no public references. | + +### `nip72ModCommunities` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 4550 | `CommunityPostApprovalEvent` | a[34550] -> COMMUNITY (A); e -> APPROVED (E); a[non-34550] -> APPROVED (A); p -> APPROVED_AUTHOR (U); k -> TAG (T) | communityAddresses() (CommunityTag), approvedEvents() (ApprovedEventTag::parseId), approvedAddresses() (ApprovedAddressTag), PTag::parseKey; k: KindTag | NIP-72 approval. Draft COMMUNITY/APPROVED rows confirmed. Content embeds the approved post JSON (containedPost()) - same id as e, not a separate link. | +| 10004 | `CommunityListEvent` | a[34550] -> SUBSCRIBED (A) | publicCommunities() / communityIds() (CommunityTag) | NIP-51 Communities list: 'NIP-72 communities the user belongs to'. Draft SUBSCRIBED row confirmed (MEMBER would match 'belongs to', but the draft chose SUBSCRIBED for follow-like lists; keep). Private (encrypted) entries never reach the graph. | +| 34550 | `CommunityDefinitionEvent` | p[moderator] -> MODERATOR (U); e/q/a -> MENTION (E,A) | moderators()/moderatorKeys() (ModeratorTag), ETag/QTag/ATag hint providers | NIP-72: p with role 'moderator'; relay tags (URLs) not modelled. Draft MODERATOR row confirmed. Quartz: ModeratorTag.parse accepts any p regardless of the role marker. UNCERTAIN: NIP-72 defines no e/q/a on 34550 yet the hint providers read them; MENTION assumed - confirm or drop. | +| 34551 | `CommunityRulesEvent` | a[34550] -> COMMUNITY (A); p[allow] -> ALLOWED (U) [props role]; p[deny] -> DENIED (U) [props role]; wot -> WOT_ROOT (U) [props depth]; k -> TAG (T) | communityAddress() (ATag), pubkeyRules() (PubkeyRuleTag::parse), wotGates() (WotTag::parse), kindRules() (KindRuleTag) | NIP-9B/9A 'Verifiable Community Rules' (unmerged upstream, 404; read from Quartz KDoc). UNCERTAIN: ALLOWED/DENIED could be one relation with props.policy, but deny vs allow is the filter every query applies (rule 4). | + +### `nipCCGeocaching` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 7516 | `GeocacheFoundLogEvent` | a[37516] -> FOUND (A) | geocache() / geocacheId() (GeocacheTag = ATag, filtered to kind 37516 or legacy kind) | NIP-CC. Props: verified (hasVerificationAttached()). The verification tag embeds a full 7517 JSON (embeddedVerification()) - not a link by id. image URLs not modelled. Non-found logs are NIP-22 1111s (ROOT/PARENT to the 37516). DRAFT FIX: draft lists geocaching among REFERENCE-only experimental kinds; now classified. | +| 7517 | `GeocacheVerificationEvent` | a[:] finder -> FINDER (U); a[:] cache -> VERIFIED (A) | finder() / verifiedCache() (FinderCacheTag::parseFinder / parseCache) | NIP-CC. The a tag is a non-standard composite ':', so a shape-based ATag parser would misread it. Signed by the cache's verification key, not the finder (so AUTHOR = the key named by 37516's verification tag). | +| 37516 | `GeocacheListingEvent` | F -> WINNER (U); verification -> VERIFIER (U); t -> HASHTAG (T); g -> TAG (T) | firstToFindWinner() (FirstToFindWinnerTag, F), verificationKey() (VerificationKeyTag), cacheType()/isArchived() (t), geohashes() | NIP-CC. t here is the cache type / 'archived', not a free hashtag (HASHTAG per the rule, flagged). r are relay URLs for logs (NOT web refs) - not modelled; must not become TAG r. n, D, T, S, hint, mission, image not modelled. UNCERTAIN: VERIFIER is a dedicated key, not a person's identity. | +| 37517 | `GeocacheCurationListEvent` | a[37516] -> CURATED (A); g -> TAG (T) | curatedGeocaches() / curatedAddresses() (ATag), geohashes() | NIP-CC curation list. Draft CURATED row (37517) confirmed. | + +### `nipF4Podcasts` (4) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 54 | `PodcastEpisodeEvent` | *none* | – | NIP-F4 kind 54: title, image, description, audio (URLs). Authored by the podcast's own key, so AUTHOR is the show. | +| 10054 | `FavoritePodcastsListEvent` | p -> FAVORITE (U) | publicFavorites() (UserTag::parse) | NIP-51 / NIP-F4. NIP-51 also allows url (RSS feed URLs) - class ignores them, not modelled. Quartz gap: no PubKeyHintProvider. UNCERTAIN: alternative is BOOKMARK with a U target (NIP-51 literally says 'bookmark') or SUBSCRIBED (NIP-F4: 'publicly advertise to listening to'). | +| 10064 | `AuthoredPodcastsEvent` | p -> AUTHORED (U) | authoredKeys() / linkedPubKeys() (UserTag::parseKey) | NIP-F4 'Authored Podcasts' (spec text says kind 10164 once but the example and NIP-51 say 10064). DRAFT FIX: draft MEMBER row lists 10064; this is not a membership set but an authorship claim, which a query must join with 10154's PODCAST_AUTHOR (both directions must agree). | +| 10154 | `PodcastMetadataEvent` | p -> PODCAST_AUTHOR (U) [props role host/cohost/editor] | claimedAuthors() (AuthorTag::parse) | NIP-F4: the claim 'shouldn't be blindly trusted' until matched by the author's 10064 (AUTHORED). website/image URLs not modelled. Quartz: AuthorTag drops unknown roles (role null) and no PubKeyHintProvider. UNCERTAIN: could reuse PARTICIPANT(props role) as NIP-53 does for Host. | + +### `nip17Dm` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 14 | `ChatMessageEvent` | p -> RECIPIENT (U); e -> PARENT (E); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U); zap -> ZAP_SPLIT (U) | recipientsPubKey() (BaseDMGroupEvent, PTag); replyTo() (ETag::parseId); q and content citations -> new parser needed (citedNIP19 lives on BaseNoteEvent, not BaseDMGroupEvent); zapSplitSetup() | NIP-17: p = receivers, e = 'the direct parent message this post is replying to', q MAY cite NIP-21 in content. Draft rows verified; DRAFT FIX: QUOTE and MENTION kinds should include 14. subject tag is a value, not modelled. Rumor kind: normally only reaches a store unwrapped. | +| 15 | `ChatMessageEncryptedFileHeaderEvent` | p -> RECIPIENT (U); e[reply] -> PARENT (E) | recipientsPubKey() (BaseDMGroupEvent); replyTo() (ETag::parseId) | NIP-17 file message. DRAFT FIX: PARENT kinds should list 15. x/ox are blob hashes of an encrypted file, content is the file URL: not modelled. No hint provider for the e tag (Quartz gap, like kind 4). | +| 10050 | `DmRelayListEvent` | *none* | RelayTag::parse (relays()) | NIP-17 DM inbox relays: relay URLs only: not modelled. | + +### `nip57Zaps` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9733 | `PrivateZapEvent` | e -> ZAPPED (E); a -> ZAPPED (A); p -> ZAP_RECIPIENT (U); k -> TAG (T) | ETag::parseId, ATag::parseAddressId, PTag::parseKey (hint providers); KindTag | NIP-57 private zap: the decrypted inner event of an anon tag, built by PrivateZapRequestBuilder from the zap request's tags minus `anon`, so it carries the same e/a/p/k. Draft rows verified. | +| 9734 | `ZapRequestEvent` | e -> ZAPPED (E); a -> ZAPPED (A); p -> ZAP_RECIPIENT (U); k -> TAG (T) | zappedPost() (ETag), ATag::parseAddress, zappedAuthor() (PTag), KindTag; amount tag for props msats | NIP-57 Appendix A/D: exactly one p, 0 or 1 e, optional a, k. Props msats from `amount`. relays/lnurl/anon/poll_option and NIP-29 h: not modelled. Draft rows verified. | +| 9735 | `ZapReceiptEvent` | e -> ZAPPED (E); a -> ZAPPED (A); p -> ZAP_RECIPIENT (U); P -> ZAP_SENDER (U); description (embedded 9734) -> ZAP_REQUEST (E); k -> TAG (T) | zappedPost(), ATag::parseAddress, zappedAuthor(); P -> new parser needed (Quartz reads only zappedRequestAuthor() = zapRequest?.pubKey); zapRequest (containedPost()) | NIP-57 Appendix E. Props msats from bolt11 (amount). ZAP_SENDER should come from P (NIP-57: 'P tag from the pubkey of the zap request (zap sender)'), falling back to the embedded request's pubkey; for anonymous zaps it is a throwaway key. Known upstream: ZapReceiptEvent omits the zap sender from its hint providers. UNCERTAIN: ZAP_REQUEST targets an event that is normally never published to relays. | + +### `nip59Giftwrap` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 13 | `SealEvent` | *none* | n/a (content is NIP-44 encrypted rumor; tags normally empty, optional expiration) | NIP-59. The rumor inside is its own event once unsealed; no link from the seal (innerEventId is local runtime state). | +| 1059 | `GiftWrapEvent` | p -> RECIPIENT (U) | recipientPubKey() (firstTagValue p) / PTag::parseKey | NIP-59/NIP-17. Signed by a throwaway key; content encrypted (inner event not linked). Draft row verified. | +| 21059 | `EphemeralGiftWrapEvent` | p -> RECIPIENT (U) | recipientPubKey() (inherited from GiftWrapEvent) | NIP-59 ephemeral gift wrap (CEP-19 uses it too). Draft row verified. | + +### `nip5dNapplets` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 5129 | `NappletSnapshotEvent` | a -> SNAPSHOTTED (A); A -> ORIGIN (A); app -> APP (A) | new parser needed (NappletManifest reads only path/server/requires/x/title/description/source/icon) | NIP-5D napplets are not on nostr-protocol/nips master (spec fetch 404); per NappletManifest they 'carry the same NIP-5A tag set', so the 5128 snapshot rules are applied by analogy. UNCERTAIN: whole row; Quartz build() writes none of a/A/app. path hashes, server (blossom) and source URLs: not modelled (source may be a NIP-34 nostr:// git URL - a future REPOSITORY candidate). | +| 15129 | `RootNappletEvent` | a -> COPIED (A); A -> ORIGIN (A); app -> APP (A) | new parser needed (NappletManifest) | NIP-5D root napplet; 'carries the NIP-5A tag set' (NappletManifest). requires = NAP capability domains (values, not modelled). UNCERTAIN: NIP-5D not on nips master; applies NIP-5A rules by analogy; Quartz writes none of these tags. | +| 35129 | `NamedNappletEvent` | a -> COPIED (A); A -> ORIGIN (A); app -> APP (A) | new parser needed (NappletManifest) | NIP-5D named napplet; NIP-5A tag set by analogy. UNCERTAIN: NIP-5D not on nips master. | + +### `nip87Ecash` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 38000 | `MintRecommendationEvent` | a[38172/38173] -> RECOMMENDED (A) [props platform cashu/fedimint]; k -> TAG (T) | mintEventAddresses() (raw a values - no Address validation), mintEventKind() (k) | NIP-87 recommendation. DRAFT FIX: RECOMMENDED kinds should add 38000. u values are mint URLs / fedimint invite codes - not modelled. Quartz bug: 38000, 38172, 38173 extend Event, not BaseAddressableEvent, though they are addressable (d-tagged, 3xxxx); mintEventAddresses() returns unvalidated strings. | +| 38172 | `CashuMintEvent` | *none* | – | NIP-87 cashu mint: d is the MINT's pubkey (not a Nostr user), u mint URL, nuts, n. Not modelled. Quartz: extends Event, not BaseAddressableEvent. | +| 38173 | `FedimintEvent` | *none* | – | NIP-87 fedimint: d federation id, u invite codes, modules, n. Quartz: extends Event, not BaseAddressableEvent. | + +### `nipB1Bolt12Zaps` (3) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9736 | `Bolt12ZapEvent` | p -> ZAP_RECIPIENT (U); P -> ZAP_SENDER (U); e -> ZAPPED (E); a -> ZAPPED (A); k -> TAG (T) | recipient() (PTag), payer() (PayerTag, P), zappedEvent() (ETag), zappedAddress() (ATag), zappedKind(), amount() (msats), zapIntent (embedded 9737) | NIP-B1 not merged upstream (404); read from Quartz KDoc. DRAFT FIX: ZAP_SENDER kinds should add 9736 (the P payer tag, NIP-57's word). Props msats = amount(). description embeds the 9737 intent (its id is not a tag; not modelled). Anonymous zaps have no P. | +| 9737 | `Bolt12ZapIntentEvent` | p -> ZAP_RECIPIENT (U); e -> ZAPPED (E); a -> ZAPPED (A); k -> TAG (T) | recipient() (PTag), zappedEvent() (ETag), zappedAddress() (ATag), zappedKind(), amount() | NIP-B1 (unmerged). Draft lists 9737 under ZAPPED. UNCERTAIN: an intent is not a payment ('never counted on its own'), so ZAPPED/ZAP_RECIPIENT counts must filter on source kind 9736 - same situation as 9734 requests; the signer is the would-be sender (AUTHOR). | +| 10058 | `Bolt12OfferListEvent` | *none* | – | NIP-B1 (unmerged): offer tags (BOLT12 offers) only. | + +### `contextvm` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 11316 | `CvmServerAnnouncementEvent` | *none* | DiscoverySurface.parse(tags) (name/about/picture/website/support_* flags only) | ContextVM CEP-6 (+CEP-23/35). Discovery tags are self-description values; `p`/`e` are routing tags and CvmTags.ROUTING excludes them from the surface; not expected on an announcement. UNCERTAIN: CEP-17 `r` relay tags if present are relay URLs (not modelled). Kind number from CvmKinds.SERVER_ANNOUNCEMENT = 11316. | +| 11317 | `CvmToolsListEvent` | i -> TAG (T); k -> TAG (T) | CommonToolSchema.parseExternalIds(tags) (i); k written by CommonToolSchema.externalKindTag() | ContextVM CEP-6 + CEP-15 common tool schemas: NIP-73-style `[i, , ]` + `[k, io.contextvm/common-schema]` on the announcement. Content is the tools JSON (no nostr refs). UNCERTAIN: CEP-15 says 'one per announcement event' without naming 11316 vs 11317; Quartz's builder is not bound to a class, so the same i/k may also appear on 11316. | + +### `nip18Reposts` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 6 | `RepostEvent` | e -> REPOSTED (E); a -> REPOSTED (A); p -> REPOSTED_AUTHOR (U); k -> TAG (T) | boostedEventId()/boostedAddress() (last e/a), originalAuthorKeys() (PTag), boostedKind() (KindTag) | NIP-18. Content embeds the reposted event JSON (containedPost()) = the same id as e; not a separate link. Kind 6 per NIP-18 is only for kind 1 but Quartz writes `a` for addressables. Draft rows verified. Note boostedEventId takes the LAST e while linkedEventIds lists all; extra e tags (non-spec) would get no meaning - treat non-last e/p as MENTION. | +| 16 | `GenericRepostEvent` | e -> REPOSTED (E); a -> REPOSTED (A); p -> REPOSTED_AUTHOR (U); k -> TAG (T) | boostedEventId()/boostedAddress(), originalAuthorKeys(), boostedKind() | NIP-18 generic repost; a for replaceables, content JSON when a is absent (same id as e, not a separate link). Draft rows verified. | + +### `nip25Reactions` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 7 | `ReactionEvent` | last e -> REACTED (E); a -> REACTED (A); last p -> REACTED_AUTHOR (U); earlier e -> MENTION (E); earlier p -> MENTION (U); k -> TAG (T); emoji[emoji-set-address] -> EMOJI_SET (A) | ETag::parseId, ATag::parseAddress, PTag::parseKey (need last-of, see note), KindTag; EmojiUrlTag (slot 3 new parser) | NIP-25: 'the target event id should be last of the e tags' and 'the target event pubkey should be last of the p tags' (extra e/p are legacy thread copies - MENTION). Quartz bug: originalPost() / originalAuthor() return ALL e ids / p keys, not the last, while the draft cites them for REACTED/REACTED_AUTHOR. DRAFT FIX: cite lastNotNullOfOrNull(ETag::parseId)/(PTag::parseKey), not originalPost()/originalAuthor(). | +| 17 | `ExternalReactionEvent` | i -> REACTED (T); k -> TAG (T); emoji[emoji-set-address] -> EMOJI_SET (A) | externalIds() (ExternalTargetTag::parse), externalKinds() (ReplyKindTag::parse); EmojiUrlTag slot 3 new parser | NIP-25 external content reactions with NIP-73 k+i; several i pairs possible (show + episode), each a REACTED. The i hint (URL) is not a target. Draft row verified. | + +### `nip30CustomEmoji` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10030 | `EmojiListEvent` | a -> MEMBER (A); emoji[emoji-set-address] -> EMOJI_SET (A) | emojiPackIds() (ATag::parseAddressId); emoji tags: EmojiUrlTag (slot 3 new parser) | NIP-51 'Emojis: user preferred emojis and pointers to emoji sets' (a = kind 30030). Draft lists 10030 under MEMBER. UNCERTAIN / possible DRAFT FIX: the a entries are sets the user USES (a selection), which reads closer to SUBSCRIBED than to membership; rule 7 would name it after the list ('emoji sets'). Loose emoji tags (URLs) not modelled. | +| 30030 | `EmojiPackEvent` | emoji[emoji-set-address] -> EMOJI_SET (A) | tags.emojis() (EmojiUrlTag; slot 3 new parser); private emojis in NIP-44 content | NIP-51 emoji set / NIP-30. The emoji tags themselves are shortcode+URL (not modelled); only the optional 4th slot (the set an emoji came from, NIP-30) is a link. UNCERTAIN: may point at the pack itself - skip self-links. | + +### `nip35Torrents` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 2003 | `TorrentEvent` | i -> TAG (T); t -> HASHTAG (T); q -> QUOTE (E,A); p -> MENTION (U); r -> TAG (T) | HashtagTag/hashtags(); QTag; PTag; i -> new parser needed (TorrentEvent has no i accessor) | NIP-35: i = tcat/newznab/imdb/tmdb/... ids, t = categories. Quartz build() turns nostr: URIs in the description into q (note/nevent/naddr) and p (npub/nprofile via NPub.toQuoteTagArray = PTag) and URLs into r. x/btih info hash, file, tracker: not modelled. Content nostr: is not parsed on read (no citedNIP19 on this class). DRAFT FIX: remove 2003 from the unclassified list. | +| 2004 | `TorrentCommentEvent` | e[root] (or first) -> ROOT (E); e[reply] (or last) -> PARENT (E); middle unmarked e -> MENTION (E); p equal to parent author -> PARENT_AUTHOR (U); other p -> MENTION (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U) | torrent() / torrentIds() (MarkedETag::parseRoot, fallback first ETag), BaseThreadedEvent.reply()/markedReply(), PTag, QTag, citedNIP19() | NIP-35: 'works exactly like a kind 1 and should follow NIP-10'; the root is the 2003 torrent. Deprecated in Quartz (replaced by NIP-22). DRAFT FIX: ROOT, QUOTE and MENTION kinds should list 2004 (draft has it only under PARENT). | + +### `nip37Drafts` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10013 | `PrivateOutboxRelayListEvent` | *none* | RelayTag::parse (publicRelays()); private relays in NIP-44 content | NIP-37 private-outbox relay list: relay URLs only: not modelled. | +| 31234 | `DraftWrapEvent` | k -> TAG (T); e[root] -> ROOT (E); e[reply] -> PARENT (E); a -> ROOT (A) | KindTag (kind(draft.kind)); exposed tags from ExposeInDraft.exposeInDraft() (ChannelMessageEvent: e root/reply; LiveActivitiesChatMessageEvent: a activity + e reply) - MarkedETag/ATag | NIP-37. The draft itself is NIP-44 encrypted (no content links). Quartz copies the draft's thread anchors (channel, live activity, reply) into public tags so a draft shows in context; they carry the inner kind's meaning (the k tag says which). UNCERTAIN: whether exposed anchors of an unpublished draft should be graph links at all, or get DRAFT_-prefixed relations; kept as ROOT/PARENT per rule 2. | + +### `nip5aStaticWebsites` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 15128 | `RootSiteEvent` | a -> COPIED (A); A -> ORIGIN (A); app -> APP (A) | new parser needed (class reads only path/server/title/description/source/icon) | NIP-5A. path (blob sha256), x aggregate hash, server (blossom), source (URL or NIP-34 nostr:// git URL): not modelled. UNCERTAIN: COPIED/ORIGIN could reuse FORK/ROOT (rule 2: a copy is a fork; NIP-22 uses uppercase for the root) - chose the NIP's words per rule 7. | +| 35128 | `NamedSiteEvent` | a -> COPIED (A); A -> ORIGIN (A); app -> APP (A) | new parser needed (class reads only path/server/title/description/source/icon) | NIP-5A named site (d = identifier). Same notes as 15128. | + +### `nip61Nutzaps` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9321 | `NutzapEvent` | e -> ZAPPED (E); p -> ZAP_RECIPIENT (U); k -> TAG (T) | ETag::parseId, PTag::parseKey (no zappedEvent()/recipient() accessors), claimedSatsTotal() for props | NIP-61: 'p is the Nostr identity public key of nutzap recipient', 'e is the event that is being nutzapped'. Draft ZAPPED/ZAP_RECIPIENT rows confirmed; props msats = claimedSatsTotal*1000 (sender-claimed). u = mint URL, not modelled. The sender is the AUTHOR. NIP-61 has no a (addressable) target. | +| 10019 | `NutzapInfoEvent` | *none* | – | NIP-61: relay (URLs), mint (URLs), pubkey = the P2PK key, which 'MUST NOT' be the user's Nostr key - not a User node; not modelled. | + +### `nip66RelayMonitor` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10166 | `RelayMonitorEvent` | g -> TAG (T) | geohashes() | NIP-66 monitor announcement: frequency, timeout, c (checks), g. No E/A/U references. | +| 30166 | `RelayDiscoveryEvent` | t -> HASHTAG (T); g -> TAG (T); k -> TAG (T) | topics() (hashtags), geohashes(), acceptedKinds() (AcceptedKindTag, k) | NIP-66: d is the relay URL (not modelled); n, N, R, T, rtt-* are relay attributes. l (language) is in the spec example but not read by the class. | + +### `nip78AppData` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 78 | `AppDataEvent` | *none* | – | NIP-78: tags are arbitrary, app-private and non-interoperable; class reads only d. DRAFT FIX: draft Coverage lists 'app data and handlers (78, 30078, 31990)' among kinds carrying references; 78/30078 carry none by spec. | +| 30078 | `AppSpecificDataEvent` | *none* | – | NIP-78: arbitrary app-private tags; class reads only d. DRAFT FIX: see 78 - listed in Coverage as carrying references, but carries none by spec. | + +### `nip88Polls` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1018 | `PollResponseEvent` | e -> POLL (E); p -> POLL_AUTHOR (U) | poll() / PollTag::parseId, PTag::parseKey (written by notifyAuthor()) | NIP-88: 'an e tag with the poll event it is referencing, followed by one or more response tags'. Draft POLL row confirmed. Props on POLL: responses (response tag option ids). Quartz: p is an Amethyst convention, not NIP-88. | +| 1068 | `PollEvent` | *none* | – | NIP-88 poll: option, relay (URLs, not modelled), polltype, endsAt. No references. | + +### `nip89AppHandlers` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 31989 | `AppRecommendationEvent` | a[31990] -> RECOMMENDED (A) [props platform] | recommendationAddresses() (RecommendationTag::parseAddressId) | NIP-89. Draft RECOMMENDED row confirmed. d is the recommended kind number (value, not in the allowlist). | +| 31990 | `AppDefinitionEvent` | a -> SITE_MANIFEST (A); latest -> SITE_MANIFEST (A) [props release=latest]; next -> SITE_MANIFEST (A) [props release=next]; client -> CLIENT (A); k -> TAG (T); t -> HASHTAG (T) | relatedAddresses() (ATag), client() (ClientTag), supportedKinds() (KindTag), categories() (hashtags); latest/next: new parser needed | NIP-89 handler information. Platform links (web/ios/android URLs) not modelled. UNCERTAIN: whether latest vs next deserve two relations (rule 4) - props chosen. CLIENT applies to every kind (Quartz nip89AppHandlers/clientTag); it belongs in the shared default, not per class. Draft Coverage lists 31990 as unclassified. | + +### `nipA0VoiceMessages` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1222 | `VoiceEvent` | *none* | – | NIP-A0: content is an audio URL; t/g MAY be included per other NIPs but the class does not read them (would be HASHTAG/TAG if added). | +| 1244 | `VoiceReplyEvent` | E/A[root scope] -> ROOT (E,A); P -> ROOT_AUTHOR (U); e -> PARENT (E); p -> PARENT_AUTHOR (U); K/k -> TAG (T) | replyingTo()/markedReplyTos() (ReplyEventTag::parseKey, e), replyAuthorKeys() (ReplyAuthorTag::parseKey, p), directKinds() (k); root scope E/A/P: new parser needed (nip22Comments tag parsers exist) | NIP-A0: kind 1244 'MUST follow the structure of NIP-22'. Draft ROOT/PARENT/ROOT_AUTHOR/PARENT_AUTHOR rows for 1244 match the spec. Quartz bug: VoiceReplyEvent.build writes only e/k/p (parent item) and no E/K/P root scope, and the class reads no root; ReplyEventTag reads only e, so a parent given as an a tag is missed; class implements no hint providers. | + +### `nipB7Blossom` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10063 | `BlossomServersEvent` | *none* | – | NIP-B7: server URLs - not modelled. | +| 24242 | `BlossomAuthorizationEvent` | *none* | – | NIP-B7/BUD auth: t is the VERB (upload/get/delete/list), not a hashtag, and x a blob hash; server/expiration. Emitting HASHTAG for this t would pollute topics - exclude. Short-lived auth token, normally not stored. | + +### `nipXXPodcasting20` (2) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 30054 | `Podcasting20EpisodeEvent` | edit -> EDITED (E); t -> HASHTAG (T) | editsEventId() (EditTag::parse), topics() (hashtags) | Podcasting-2.0 draft (not a NIP; podstr). edit = 'the event id of the original publication when an addressable episode/trailer is updated' - fits draft EDITED. person tags carry names/URLs, not pubkeys (not modelled). Quartz: EditTag.parse does not check 64-hex. | +| 30055 | `Podcasting20TrailerEvent` | *none* | – | Podcasting-2.0 draft trailer: title, url, pubdate, length, type, season - no references. | + +### `nip01Core` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 0 | `MetadataEvent` | i -> TAG (T); emoji[emoji-set-address] -> EMOJI_SET (A) | IdentityClaimTag::parse (i); EmojiUrlTag does not read slot 3 -> new parser needed for EMOJI_SET | NIP-01 / NIP-39 (identity claims mirrored as `i` tags, nips PR 1770 tag-names) / NIP-30. Content is JSON; Quartz does not parse nostr: URIs in `about` (no citedNIP19 on this class) so no content MENTION. Other name/picture/... tags are plain values, not modelled. | + +### `nip02FollowList` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 3 | `ContactListEvent` | p -> FOLLOW (U) | ContactTag::parseKey / parseValid (petname, relay hint) | NIP-02. Content relay map (legacy) is relay URLs: not modelled. Draft row verified. | + +### `nip03Timestamp` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1040 | `OtsEvent` | e -> TIMESTAMPED (E); k -> TAG (T) | TargetEventTag::parseId (digestEventId()); targetKind (KindTag) | NIP-03: e = target event, k = target kind. Draft row verified. | + +### `nip04Dm` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 4 | `EncryptedDmEvent` | p -> RECIPIENT (U); e -> PARENT (E) | PTag::parseKey (recipientPubKey()); MarkedETag::parseId (replyTo()) | NIP-04: p = receiver; e = 'the previous message in a conversation or a message we are explicitly replying to'. DRAFT FIX: PARENT kinds should list 4. Content encrypted; NIP-04 says clients should not rewrite nostr refs into tags. EncryptedDmEvent does not implement EventHintProvider for its e tag (only pubkeys) - minor Quartz gap. | + +### `nip09Deletions` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 5 | `DeletionRequestEvent` | e -> DELETED (E); a -> DELETED (A); p -> DELETED_AUTHOR (U); k -> TAG (T) | ETag::parseId (deleteEventIds()), ATag::parseAddressId (deleteAddressIds()), PTag::parseKey (new accessor), KindTag (kinds()) | NIP-09 defines only e/a/k (the p is Quartz practice). Draft DELETED row verified. | + +### `nip10Notes` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1 | `TextNoteEvent` | e[root] (or first unmarked) -> ROOT (E); e[reply] (or last unmarked) -> PARENT (E); e[mention] or middle unmarked e -> MENTION (E); e[fork] -> FORK (E); a[root] -> ROOT (A); a[reply] -> PARENT (A); a[fork] -> FORK (A); a to kind 34550 -> COMMUNITY (A); other a -> MENTION (A); p equal to the parent's author (e[reply] pubkey slot) -> PARENT_AUTHOR (U); other p -> MENTION (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U); t -> HASHTAG (T); r -> TAG (T); g -> TAG (T); zap -> ZAP_SPLIT (U); emoji[emoji-set-address] -> EMOJI_SET (A) | BaseThreadedEvent.markedRoot/unmarkedRoot/markedReply/unmarkedReply, MarkedETag.parseAllThreadTags/parseForkedEventId (MARKER.ROOT/REPLY/MENTION/FORK); ATag::parseAddress; PTag::parseKey; QTag::parseEventId/parseAddressId; citedNIP19(); Event.hashtags(); zapSplitSetup(); a-marker and PARENT_AUTHOR matching -> new parser needed | NIP-10 (+NIP-18 q, NIP-27 content, NIP-72 legacy community a, NIP-57 zap). DRAFT FIX: PARENT_AUTHOR (and ROOT_AUTHOR from e[root] pubkey slot) should include kind 1 - NIP-10 says the replied-to author is added to p; without it 'replies to my notes' needs a 2-hop join. DRAFT FIX: FORK targets E and A (isAFork accepts a or e with fork marker). NIP-10 no longer defines a `mention` marker; Quartz still parses MARKER.MENTION (legacy). Quartz bug: forkFromAddress() = first ATag::parseAddress regardless of fork marker (a community a-tag reads as the fork source); WikiArticleEvent uses ForkTag correctly. Known: QTag.parseAddressId rejects every address. UNCERTAIN: whether a p that is both parent author and notified thread member emits only PARENT_AUTHOR (proposed) or both. | + +### `nip22Comments` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1111 | `CommentEvent` | E -> ROOT (E); A -> ROOT (A); I -> ROOT (T); K -> TAG (T); P -> ROOT_AUTHOR (U); e -> PARENT (E); a -> PARENT (A); i -> PARENT (T); k -> TAG (T); p equal to the parent's author -> PARENT_AUTHOR (U); other p -> MENTION (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U); t -> HASHTAG (T); zap -> ZAP_SPLIT (U); emoji[emoji-set-address] -> EMOJI_SET (A) | RootEventTag/RootAddressTag/RootIdentifierTag/RootKindTag/RootAuthorTag, ReplyEventTag/ReplyAddressTag/ReplyIdentifierTag/ReplyKindTag/ReplyAuthorTag (nip22Comments/tags), QTag, citedNIP19(), hashtags(), zapSplitSetup() | NIP-22. DRAFT FIX: ROOT and PARENT need target T for the I/i external-identifier scopes (hashtag, geohash, URL comments). NIP-22 also says 'p tags SHOULD be used when mentioning pubkeys in content' so a lowercase p is PARENT_AUTHOR only when it matches the parent (e tag's pubkey slot / replyAuthor()), else MENTION; ReplyAuthorTag currently treats every p as the parent author (Quartz ambiguity). UNCERTAIN: an A root of kind 34550 is a NIP-72 community post - emit COMMUNITY (A) in addition to ROOT? Known: QTag.parseAddressId rejects every address. | + +### `nip23LongContent` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 30023 | `LongFormContentEvent` | q -> QUOTE (E,A); e -> MENTION (E); a -> MENTION (A); p -> MENTION (U); content nostr: -> MENTION (E,A,U); t -> HASHTAG (T); zap -> ZAP_SPLIT (U); emoji[emoji-set-address] -> EMOJI_SET (A) | QTag::parseEventId/parseAddressId, PTag::parseKey, citedNIP19(), topics()/hashtags(), zapSplitSetup(); e/a not read by hint providers -> new parser needed | NIP-23: 'references to other notes, articles or profiles must be made according to NIP-27 ... optionally adding tags for these' - so e/a/p are mention tags. Although it extends BaseThreadedEvent it has no reply semantics (root()/reply() must not be used for it). Known: QTag.parseAddressId rejects every address. Draft rows verified. | + +### `nip32Labeling` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1985 | `LabelEvent` | e -> LABELED (E); a -> LABELED (A); p -> LABELED (U); t -> LABELED (T); r -> LABELED (T); l -> TAG (T); L -> TAG (T) | labeledEvents() (ETag), labeledAddresses() (ATag), labeledPubKeys() (PTag), labeledHashtags() (HashtagTag), labeledRelayUrls() (r), labels()/namespaces() | NIP-32. Props labels (l values with namespace) on each LABELED. NOTE: on 1985 `t` and `r` are label TARGETS, not the event's own topics - they must NOT fall back to HASHTAG/TAG. r may be a relay URL; kept as a T target because it is what is labeled (UNCERTAIN given 'relay URLs not modelled in v1'). With no target tag, the labels apply to the label event itself (no link). Draft row verified. | + +### `nip38UserStatus` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 30315 | `UserStatusEvent` | p -> LINKED (U); e -> LINKED (E); a -> LINKED (A); r -> TAG (T); emoji[emoji-set-address] -> EMOJI_SET (A) | create() writes PTag/ETag/ATag but there are no readers (only firstTaggedUrl() for r) -> new parser needed | NIP-38. d = status type (general/music), expiration: values. DRAFT FIX: remove 30315 from the REFERENCE list. UNCERTAIN: LINKED vs reusing MENTION. | + +### `nip39ExtIdentities` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10011 | `ExternalIdentitiesEvent` | i -> TAG (T) | IdentityClaimTag::parse (claims via replaceClaims) | NIP-39: i = platform:identity with proof. Plain value tag. | + +### `nip42RelayAuth` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 22242 | `RelayAuthEvent` | *none* | relay() (RelayTag), challenge() (ChallengeTag) | NIP-42: relay URL + challenge string only: not modelled. | + +### `nip46RemoteSigner` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 24133 | `NostrConnectEvent` | p -> RECIPIENT (U) | recipientPubKey()/verifiedRecipientPubKey() (first p) | NIP-46: client p-tags remote-signer and vice versa, encrypting to it. DRAFT FIX: RECIPIENT kinds could list 24133, 23194, 23197. Ephemeral. | + +### `nip50Search` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10007 | `SearchRelayListEvent` | *none* | tags.relays() (RelayTag) | NIP-50/NIP-51 search relay list: relay URLs (public + NIP-44 private): not modelled. | + +### `nip56Reports` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1984 | `ReportEvent` | p (report names no e/a/x) -> REPORTED_USER (U); p (report also names e/a/x) -> REPORTED_AUTHOR (U); e -> REPORTED (E); a -> REPORTED (A); x -> REPORTED (T); l -> TAG (T); L -> TAG (T) | ReportedAuthorTag / ReportedEventTag / ReportedAddressTag (typed, DefaultReportTag fallback), HashSha256Tag (x); reportedAuthorsWithOwnType() | NIP-56. Props report/report_raw on all three. server tag = media server URL: not modelled. Split must be by presence of e/a/x, NOT by whether p carries its own type: Quartz's own build() writes the type on both e and p. Draft rows verified. | + +### `nip62RequestToVanish` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 62 | `RequestToVanishEvent` | *none* | – | NIP-62: only relay tags (relay URL or ALL_RELAYS) - not modelled. The vanish effect is about the AUTHOR, already the AUTHOR link. | + +### `nip65RelayList` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10002 | `AdvertisedRelayListEvent` | *none* | – | NIP-65: r tags are relay URLs (read/write markers) - not modelled; NOT the TAG r (web url) meaning. | + +### `nip68Picture` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 20 | `PictureEvent` | p -> TAGGED (U); imeta annotate-user -> TAGGED (U) [props x,y]; t -> HASHTAG (T); g -> TAG (T) | hashtags(), geohashes(); imetaTags() -> PictureMeta.annotations (UserAnnotationTag); p: new parser needed (class has no p accessor; PTag::parseKey) | NIP-68 also defines m, x (hashes), location, L/l (not read by the class; x/location not modelled). Quartz gap: no PubKeyHintProvider though p tags are spec'd. UNCERTAIN: could merge with PARTICIPANT (NIP-71 video 'participant') if the maintainer prefers one people-in-media relation. | + +### `nip69P2pOrderEvents` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 38383 | `P2POrderEvent` | *none* | – | NIP-69: k is the order type (sell/buy), not a kind - do not emit TAG k; f, s, amt, fa, pm, premium, source (URL), network, layer, name, g (spec; not read by the class), bond, y, z. No E/A/U references. | + +### `nip75ZapGoals` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9041 | `ZapGoalEvent` | e -> FUNDED (E); a -> FUNDED (A); zap -> ZAP_SPLIT (U) [props weight]; p -> MENTION (U); r -> TAG (T); t -> HASHTAG (T) | ETag/ATag::parseId (linked()), PTag::parseKey, topics() (hashtags); zap: Event.zapSplitSetup() (ZapSplitSetupParser, pubkey form only); r: new parser needed (builder writes it via reference()) | NIP-75. Draft lists zap goals (9041) as REFERENCE-only; classified here. NIP-75 defines no p or e tag (Quartz writes e as well as a for addressable targets, and reads p): UNCERTAIN p meaning, MENTION chosen. BENEFICIARY is cross-cutting: any event with NIP-57 zap tags. | + +### `nip7DThreads` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 11 | `ThreadEvent` | *none* | – | NIP-7D: only title (and nostrord's subject). Replies are NIP-22 1111s pointing at it. NIP-29 h group tag is not an E/A/U target. Class extends Event (not BaseNoteEvent) so no nostr: content parsing; if content citations are wanted later it would be MENTION via content (new parser). | + +### `nip84Highlights` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9802 | `HighlightEvent` | e -> HIGHLIGHTED (E); a -> HIGHLIGHTED (A); i -> HIGHLIGHTED (T); r[source or unmarked] -> HIGHLIGHTED (T); p[author or no role, editor] -> HIGHLIGHTED_AUTHOR (U) [props role]; p[mention] -> MENTION (U); r[mention] -> TAG (T); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U) | inPostVersion()/inPostAddress() (first e/a), inExternalIds() (ReplyIdentifierTag, i), inReference() (r, source/mention markers), author() (p author role), PTag::parseKey, QTag, citedNIP19() | NIP-84: source via a/e, i (NIP-73), r ('may contain a URL or text'); p tags 'the original authors' with optional role (author, editor); in quote highlights p/r 'mention' marker. DRAFT FIX: HIGHLIGHTED targets should include T (i/r sources), as REACTED does for kind 17. Quartz: author() only reads the author role; editor p tags fall through to linkedPubKeys (role lost); q parsing is Amethyst-side (NIP-84 does not define q). | + +### `nip94FileMetadata` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1063 | `FileMetadataEvent` | i -> TAG (T) [torrent infohash] | torrentInfoHash() (TorrentInfoHash::parse, tag i) | NIP-94: url, m, x, ox, size, dim, magnet, i, blurhash, thumb, image, summary, alt - no E/A/U references. x/ox are blob hashes (not in the TAG allowlist). DRAFT FIX: draft Coverage lists file metadata (1063) as carrying references; only the i value tag. | + +### `nip96FileStorage` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10096 | `FileServersEvent` | *none* | – | NIP-96: server URLs - not modelled. | + +### `nip98HttpAuth` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 27235 | `HTTPAuthorizationEvent` | *none* | – | NIP-98: u (URL), method, payload hash - not modelled. | + +### `nip99Classifieds` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 30402 | `ClassifiedsEvent` | e -> MENTION (E); a -> MENTION (A); p -> MENTION (U); t -> HASHTAG (T); content nostr: -> MENTION (E,A,U) | ETag/ATag/PTag::parseKey\|parseId (hint providers), categories() (hashtags); content nostr:: new parser needed (class is BaseAddressableEvent, no citedNIP19) | NIP-99: the example's e/a tags are the events the markdown content cites (NIP-27 style), so MENTION. g is in the spec but not read by the class. Draft lists classifieds (30402) as REFERENCE-only; classified here. UNCERTAIN: NIP-99 gives e/a/p no explicit role. | + +### `nipA3PaymentTargets` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 10133 | `PaymentTargetsEvent` | *none* | – | NIP-A3: payto payment targets (lightning/bitcoin/etc.) - not Nostr entities. | + +### `nipA4PublicMessages` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 24 | `PublicMessageEvent` | p -> RECIPIENT (U); q -> QUOTE (E,A); content nostr: -> MENTION (E,A,U) | ReceiverTag::parseKey (p), citedNIP19() (eventIds/addressIds/pubKeys); q: new parser needed in this class (QTag::parseEventId/parseAddressId exist) | NIP-A4: 'p tags identify one or more receivers'; 'e tags must not be used' (Quartz strips them); q MAY cite events used in content. Draft lists 24 under both RECIPIENT and MENTION: correct only if MENTION means the content nostr: URIs - the p tags are RECIPIENT, never MENTION. Quartz gap: q tags not read by hint providers. | + +### `nipB0WebBookmarks` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 39701 | `WebBookmarkEvent` | d[url] -> BOOKMARK (T) [Tag name r]; t -> HASHTAG (T) | url() (dTagToUrl(dTag())), hashtags() | NIP-B0: 'The d tag is just their URI'. DRAFT FIX: BOOKMARK targets should include T (a web URL) for 39701. UNCERTAIN: URL values are TAG-shaped; if URLs stay out of the graph in v1, this becomes none apart from HASHTAG. Replies are NIP-22 1111s. | + +### `nipBCOnchainZaps` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 8333 | `OnchainZapEvent` | p -> ZAP_RECIPIENT (U); e -> ZAPPED (E); a -> ZAPPED (A); i -> TAG (T) [bitcoin txid]; k -> TAG (T) | recipient() (PTag), zappedEvent() (ETag), zappedAddress() (ATag), txid() (BitcoinTxIdTag, i), claimedAmountInSats() | NIP-BC is not merged upstream (404); read from Quartz KDoc. Draft ZAPPED/ZAP_RECIPIENT rows confirmed. Props msats = claimedAmountInSats*1000, sender-claimed until verified on chain. The sender is the AUTHOR (no P tag). Builder writes both a and e for addressable targets (two ZAPPED links to the same content). | + +### `nipC0CodeSnippets` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 1337 | `CodeSnippetEvent` | repo[30617 address] -> REPOSITORY (A); l -> TAG (T) | TagArray.repo() (RepoTag::parse returns the raw string; needs Address.parse to tell a 30617 coordinate from a URL - not exposed on the class), language() (l) | NIP-C0: repo 'MUST be either a standard URL or ... the address of a NIP-34 Git repository announcement'. A repo URL is not modelled. REPOSITORY (draft: 1617, 1618, 1621) extends to 1337. Also name, extension, description, runtime, license, dep (no references). | + +### `nipC7Chats` (1) + +| Kind | Class | Links | Built from | Notes | +|---|---|---|---|---| +| 9 | `ChatEvent` | q[last, the reply] -> PARENT (E); q[slot 3 pubkey of the parent] -> PARENT_AUTHOR (U); q[other] -> QUOTE (E,A); p -> MENTION (U); content nostr: -> MENTION (E,A,U) | replyingTo() (last q), quotedEvents() (QEventTag::parse incl. author slot), QTag::parseEventId/parseAddressId, PTag::parseKey, citedNIP19() (BaseNoteEvent) | NIP-C7: 'A reply to a kind 9 ... quotes the parent using a q tag'; other kinds MAY be quoted per NIP-18. Draft PARENT row (kind 9 via q) confirmed. UNCERTAIN: by tags alone a kind 9 that only quotes (not replies) is indistinguishable from a reply; Quartz takes the LAST q as parent and returns tag[1] even when it is an address (replyingTo() does not check shape). PARENT_AUTHOR from q[3] follows rule 3; NIP-C7 puts the parent's pubkey in the q tag, so no p is needed. | diff --git a/quartz/plans/2026-09-29-graph-link-vocabulary.md b/quartz/plans/2026-09-29-graph-link-vocabulary.md index fa413bca76..7984987755 100644 --- a/quartz/plans/2026-09-29-graph-link-vocabulary.md +++ b/quartz/plans/2026-09-29-graph-link-vocabulary.md @@ -2,7 +2,10 @@ 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; the kind stays on the source event only; names follow Nostr's own words (rule 7). +relation; the kind stays on the source event only; names follow Nostr's own words (rule 7); +**no fallback** — every class states the meaning of every reference it carries (rule 5). The +per-class table for all 410 Quartz classes is the +[appendix](2026-09-29-graph-link-vocabulary-appendix.md). ## Why @@ -65,12 +68,13 @@ Rules the vocabulary follows: 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 `REFERENCE` link (with `via` = the tag it came from) for every id its hint - providers name AND every generic reference tag whose value has the right shape: `e`/`E`/`q` - with a 64-hex id, `p`/`P` with a 64-hex key, `a`/`A` with a valid address. The shape half is - not optional: most kinds Quartz types implement no hint provider (measured below), and without - it their references would vanish. Classifying a kind later is an additive change. +5. **No fallback: every class says what its references mean.** Each of the 410 classes + `EventFactory` types implements `links()` (or is declared link-free), and a test holds it: a + new kind cannot land without that decision. There is no generic "reference" relation and no + rule that guesses from a value's shape. The per-class review showed why guessing is unsafe: + a 64-hex `e` in a chess start event is a board hash, the 30174 `d` is a blinded HMAC, `t` is + an auth verb in 24242 and `r` holds relay URLs in 10002. A tag that appears on every kind + (`client`, `zap`, the emoji tag's set address) is emitted once by `Event`, not per class. 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 @@ -200,23 +204,21 @@ property of the current graph schema: "user-wide reports of X" is | `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 +### Every kind: tags any event may carry -| Relation | Targets | Meaning | -|---|---|---| -| `REFERENCE` | E, A, U | A link a provider names (or a value shaped like an id) that no relation above claims. Props: `tag` | +| Relation | Targets | Meaning | Kinds | +|---|---|---|---| +| `CLIENT` | A | The NIP-89 `client` tag's handler address (3rd slot) | any | +| `ZAP_SPLIT` | U | A NIP-57 Appendix G `zap` tag: a split setting, not a payment (rule 4 keeps it apart from `ZAP_RECIPIENT`). Props: `weight` | any | +| `EMOJI_SET` | A | The optional 4th slot of a NIP-30 `emoji` tag: the 30030 set it comes from | any | -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); -- wiki merge requests (818 / 819); -- user status (30315); -- zap goals (9041); -- classifieds (30402); -- video collaboration (34238). +### Relations the per-class review adds -Each is a small, additive classification when someone needs it. +The review of all 410 classes needed **115 relations** beyond the tables above, for kinds the +tables did not reach (NIP-29 groups, NIP-90 DVMs, NIP-34 git roles, NIP-54 wiki merges, NIP-60 +cashu, NIP-71 video credits, buzz, marmot, experimental kinds…). They are listed with their +kinds and justification at the top of the [appendix](2026-09-29-graph-link-vocabulary-appendix.md) +and need the same review these tables had before they are final. ## Reading it back @@ -250,8 +252,9 @@ Decided: Open: 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. + constants) plus one `links()` per class, beside its tags. `Event` contributes only the + every-kind tags (`client`, `zap`, emoji sets). The hint providers could later be derived from + `links()`, which carry the same ids plus their meaning. 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. @@ -272,13 +275,34 @@ These were already catalogued in neo4j-eventstore's `docs/appendix-providers.md` - `ChannelCreateEvent.linkedEventIds()` returns its own id; - `ZapReceiptEvent` omits the zap sender. -New: two classes claim kind **1010**, `experimental/edits/TextNoteModificationEvent` and -`nip51Lists/goodWikiRelayList/GoodWikiRelayListEvent`. `EventFactory` can only type one of them. +Found by the per-class review (details in the appendix rows): +- **Kind collision at 20001:** `GeohashPresenceEvent` and buzz `PresenceUpdateEvent`; + `EventFactory` tells them apart by the `g` tag. (An earlier draft claimed a collision at 1010; + that was a prefix-matching mistake: `GoodWikiRelayListEvent` is 10102.) +- **Privacy:** `GeohashListEvent.create(…)` (the `NostrSignerSync` variant) swaps public and + private geohashes, publishing the private ones in clear tags. +- **Addresses:** 15 NIP-51 lists in 10000–19999 extend `PrivateTagArrayEvent`, which builds the + address from `d`, without overriding `dTag()`; a stray `d` tag splits their address. +- **Wrong target:** `ChannelHideMessageEvent.eventsToHide()` includes the channel root; on a + spec-conforming 43, `channel()` returns the hidden message. `ForkTag.parse` (30817) requires + kind 34550; the attestation `RequestTag` returns `ApprovedAddressTag`. +- **Copy-paste:** `LiveActivitiesChatMessageEvent.unmarkedReplyTos()` calls + `markedReplyTos()`; the 30298 reading state's `build()` overwrites its root and swaps summary + and image. +- **Missing NIP-22 scopes:** `VoiceReplyEvent` (1244) writes only `e`/`k`/`p`, though NIP-A0 says + it MUST follow NIP-22. +- **Missing validation:** `WinnerTag`, `AgentTag`, `ReplacedByTag`, `ConsentTag`, + `AddressMemberTag`, `EditTag`, `MarkedETag.parseAllThreadTags` and several list accessors + accept values that are not 64-hex ids or valid addresses. +- **Hint-provider gaps:** the video classes, `PictureEvent`, `VoiceReplyEvent`, the podcast lists + and every buzz class implement none, although their tags are references. ## Coverage -Measured on 2026-09-29: `EventFactory` types **410** classes. The measurement is a text scan, so -the split below is approximate; an exact per-class table is plan step 2a. +`EventFactory` types **410** classes. The [appendix](2026-09-29-graph-link-vocabulary-appendix.md) +classifies every one of them from its code and its NIP: **340** carry references, **70** carry +none. The first estimate below came from a text scan and is kept for the record; the appendix +supersedes it. - **~150 are classified above.** That covers the NIPs the graph already interprets. - **~110 carry no references.** Settings, metadata, relay and server lists, key packages, ephemeral auth. They need nothing beyond `AUTHOR` (and `ADDRESS`). @@ -298,16 +322,57 @@ the split below is approximate; an exact per-class table is plan step 2a. (9999 / 39999), torrents (2003). **Enforced, not hoped for:** a Quartz test walks every `EventFactory` kind and fails unless the -class is one of: classified (implements `LinkProvider`), explicitly `REFERENCE`-only, or -explicitly link-free. A new kind then cannot land without a decision about its links. +class implements `links()` or is explicitly link-free. A new kind then cannot land without a +decision about its links. + +## Corrections from the per-class review + +The review checked the tables above against the code and the NIP texts. To apply before +implementing (each is detailed in its appendix row): +- `REACTED` / `REACTED_AUTHOR` take the **last** `e` / `p` (NIP-25); earlier ones are `MENTION`. + Quartz's `originalPost()` / `originalAuthor()` return all of them, so they cannot be the source. +- A lowercase `p` on kinds 1 and 1111 is `PARENT_AUTHOR` only when it matches the parent's + author; otherwise it is `MENTION`. `ROOT` / `PARENT` also take **T** (NIP-22 `I`/`i` scopes). +- `PARENT` does not apply to 30818 (NIP-54 articles have none); `FORK` takes E and A. +- Kind 24's `p` tags are `RECIPIENT` only; `MENTION` there comes from content alone. +- Kind 1985's `t` / `r` are label targets (`LABELED`), not `HASHTAG` / `TAG`. +- A Buzz-style lone `reply` marker is a direct reply: both `ROOT` and `PARENT`. +- The unclassified list shrinks to nothing: every kind is now in the appendix. + +## Open decisions the review surfaced + +1. **The 115 new relations** (appendix, top table): same review as the tables had. Unified + already where groups coined synonyms: `ADDED_USER` / `REMOVED_USER`, `REQUEST` / + `REQUEST_AUTHOR` (NIP-90's "customer"), `ZAP_SPLIT` (NIP-75's "beneficiary"). Still to + decide: `APP` vs `APPLICATION`, `AUTHORED` (10064) beside `AUTHOR`, whether + `SERVICE_PROVIDER` spans NIP-85 and NIP-90 or splits (rule 4). +2. **What a group is.** NIP-29 names a group by the address of its kind 39000, signed by the + relay's key; `h` alone would merge forks that reuse an id. Options: that address when the + relay key is known, a tag value `h` with the relay as a prop, or a new `LinkTarget.Group`. + Marmot's `h` is a random global id, sound as a tag value. +3. **URLs and external ids as targets.** Kind 17 reactions, highlight sources (`r`), web + bookmarks (39701) and NIP-22 `I` scopes point at URLs or NIP-73 ids. Are those **T** + targets in v1? +4. **Value tags need a per-class opt-in.** The same letter means different things by kind, so + `HASHTAG` / `TAG` come from each class's `links()`, never from a global allowlist. +5. **Links derived from the event's own `d`** (30618 → its repository, 39001–39005 → the group, + 30177 → its agent): in `links()`, or left to the graph? +6. **References inside content JSON** (buzz 40099 / 40902 / 44100, DVM results, 30175–30177, + marketplace stalls): each needs a new parser. +7. **Private list entries** (NIP-44 encrypted NIP-51 items, encrypted DVM requests) are invisible + to any public index. Stated once, not per row. + ## Plan -1. This review: the vocabulary, the model, the open questions. Done except the two open points. - - 2a. The exact per-class coverage table (all 410), generated, as an appendix to this plan. -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. +1. This review: the vocabulary, the model, the open questions. The per-class + [appendix](2026-09-29-graph-link-vocabulary-appendix.md) is done; the open decisions above + and the 115 new relation names remain. +2. Quartz: `nip01Core/links/`, the every-kind tags on `Event`, and the coverage test; then + `links()` class by class from the appendix, starting with the NIPs the graph already + interprets (10, 18, 22, 25, 56, 57, 85, 51, 58, 72, 09), each with a golden test. The Quartz + bugs above land with the classes they affect. 3. neo4j-eventstore: derive from `links()`, schema 2.0, rewrite `docs/schema.md` and the reference queries. -4. The remaining kinds, as someone needs them. +4. The rest of the appendix, until the coverage test passes for all 410 classes. Kinds with an + unmerged or missing spec (`UNCERTAIN` rows) are decided with their maintainers.