mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
Old class names drifted from the NIP wording (or described the wrong kind). Every old name stays available as a @Deprecated typealias with a ReplaceWith, so external Quartz users get an IDE quick-fix instead of a break. Wire format is unchanged: parsing goes by kind. Renames: - FollowListEvent (39089) -> StarterPackEvent - LnZapPaymentRequestEvent/ResponseEvent (NIP-47) -> NwcRequestEvent/NwcResponseEvent - VideoHorizontalEvent/VideoVerticalEvent -> AddressableNormalVideoEvent/AddressableShortVideoEvent - CalendarEvent (31924) -> CalendarCollectionEvent - HashtagListEvent -> InterestListEvent - ContactCardEvent (30382) -> UserAssertionEvent - LnZapEvent/LnZapRequestEvent/LnZapEventInterface -> ZapReceiptEvent/ZapRequestEvent/ZapReceiptEventInterface - PrivateDmEvent -> EncryptedDmEvent, DeletionEvent -> DeletionRequestEvent - LongTextNoteEvent -> LongFormContentEvent, WikiNoteEvent -> WikiArticleEvent - PeopleListEvent -> FollowSetEvent, LabeledBookmarkListEvent -> BookmarkSetEvent - RelayFeedsListEvent -> FavoriteRelayListEvent, EmojiPackSelectionEvent -> EmojiListEvent - ChannelListEvent -> PublicChatListEvent, ChatMessageRelayListEvent -> DmRelayListEvent - SealedRumorEvent -> SealEvent, FileHeaderEvent -> FileMetadataEvent - GoalEvent -> ZapGoalEvent, StatusEvent -> UserStatusEvent - NIP90*Event -> Dvm*Event (all NIP-90 events) - NIP-29 group events get the Group prefix to separate them from NIP-43: PutUser, RemoveUser, EditMetadata, DeleteEvent, CreateInvite, UpdatePinList, JoinRequest, LeaveRequest; SupportedRolesEvent -> GroupRolesEvent Merged duplicate classes for the same kind: - nip61 TokenEvent (7375) was unreachable in EventFactory; now an alias of CashuTokenEvent. - nip61 NutzapRedemptionEvent (7376) was unreachable; CashuSpendingHistoryEvent now carries its hint providers and redeemedNutzaps(), plus CashuSpendingHistoryEvent.buildNutzapRedemption. - NIP-82 SoftwareReleaseEvent (30063) was never built by EventFactory; ReleaseArtifactSetEvent now exposes appId/version/channel/assets/releaseNotes and buildSoftwareRelease, and the app no longer re-parses the event. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VvJ8XpeYqBe8bYT3bJ7UD
233 lines
13 KiB
Markdown
233 lines
13 KiB
Markdown
---
|
||
name: nip85-trusted-assertions
|
||
description: The NIP-85 trusted-assertions model in Quartz (`nip85TrustedAssertions/`) — kind 10040 trust-provider lists, kind 30382 contact cards / user assertions, 30383 event assertions, 30384 addressable assertions, 30385 external-id assertions. Use when building or parsing these events, working with the typed tags (RankTag, HopsTag, FollowerCountTag, ServiceProviderTag/ServiceType, …), wiring a consumer that resolves a 10040 provider entry to the 30382s it signs, ranking on assertion values, or touching the GrapeRank publisher, contact-card nicknames, or the trust projection of an external store.
|
||
---
|
||
|
||
# NIP-85 Trusted Assertions — the Quartz model
|
||
|
||
Package: `quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip85TrustedAssertions/`.
|
||
NIP-85 is still an evolving spec; **this package is the operative definition** of what
|
||
Amethyst-family software writes and reads. This skill states the model (who signs what about
|
||
whom), the exact kind/d-tag/tag vocabulary, and what consumers may — and may not — assume.
|
||
|
||
## The model in one paragraph
|
||
|
||
An **assertion is signed by the asserting party** (a trust provider service, or the user
|
||
themself) **about a subject named in the d-tag**. All assertion kinds are addressable, so
|
||
"latest card by provider P about subject S" is just the addressable coordinate
|
||
`(kind, P, S)` and supersession is standard NIP-01 latest-wins. Discovery is the observer's
|
||
**kind 10040 list**: each entry says *"for metric M on kind K, I trust provider P — fetch
|
||
their assertions at relay R"*. Quartz enforces none of this cryptographically beyond normal
|
||
event signatures; the 10040→assertion link is **consumer-side convention** (see
|
||
"Authorization" below).
|
||
|
||
## Kind map
|
||
|
||
| Kind | Class | Kind class | d-tag = the subject | Content |
|
||
|---|---|---|---|---|
|
||
| 10040 | `list/TrustProviderListEvent` | replaceable | *(none — always `""`)* | NIP-44 private provider entries (optional) |
|
||
| 30382 | `users/UserAssertionEvent` | addressable | **target user's pubkey** (hex) | NIP-44 private tags (petname/summary/emoji) |
|
||
| 30383 | `events/EventAssertionEvent` | addressable | **target event id** (hex) | `""` |
|
||
| 30384 | `addressables/AddressableAssertionEvent` | addressable | **target coordinate** `kind:pubkey:dtag` | `""` |
|
||
| 30385 | `externalIds/ExternalIdAssertionEvent` | addressable | **external identifier** (e.g. `isbn:978-0-13-468599-1`) | `""` |
|
||
|
||
Addresses: `UserAssertionEvent.createAddress(owner, target)` → `Address(30382, owner, target)`
|
||
(owner = signer, target = subject). `TrustProviderListEvent.createAddress(pubKey)` uses
|
||
`FIXED_D_TAG = ""`. `AssertionEventTest.eventKindsAreCorrect` pins all five numbers.
|
||
|
||
`UserAssertionEvent` is also a `SearchableEvent` — it indexes only the **public** petname/summary
|
||
tags plus topics; the encrypted card content is intentionally never indexed.
|
||
|
||
## The 10040 provider entry (`ServiceProviderTag` / `ServiceType`)
|
||
|
||
There is **no fixed tag name**: `tag[0]` *is* the service string.
|
||
|
||
```json
|
||
["30382:rank", "<provider pubkey, 64 hex>", "wss://nip85.brainstorm.world"]
|
||
```
|
||
|
||
- `ServiceType(kind, type)` parses/renders `"<kind>:<type>"` — kind must be an int, the first
|
||
`:` splits, colons in the remainder stay in `type`. `ServiceType.isOfKind` is the
|
||
allocation-free prefix check.
|
||
- `ServiceProviderTag.parse` requires ≥3 elements, non-empty service, 64-char pubkey
|
||
(length-only check), and a **normalizable relay URL** (`RelayUrlNormalizer.normalizeOrNull`) —
|
||
entries failing any check are silently dropped, which is what keeps foreign tags like
|
||
`["client","nostria"]` out (regression-tested in `ServiceTypeParserTest`).
|
||
- Entries may be **public** (tag array) or **private** (NIP-44 content); `create`/`add` take
|
||
`isPrivate`. `remove` always needs decryption and strips from both sides by parsed-value
|
||
equality.
|
||
- `object ProviderTypes` (`list/tags/ServiceType.kt`) enumerates the *known* service types —
|
||
`30382:rank`, `30382:followers`, `30382:first_created_at`, per-metric `30383:*`/`30384:*`/
|
||
`30385:*`, etc. It is an **open vocabulary**: real 10040s in the wild (see the fiatjaf →
|
||
brainstorm fixture in `commonTest/.../nip85TrustedAssertions/ServiceParser.kt`) carry types
|
||
Quartz doesn't enumerate (`30382:personalizedGrapeRank_influence`, `30382:hops`,
|
||
`30382:verifiedFollowersCount`, …). Parse any `kind:type`; special-case only what you rank on.
|
||
|
||
## Authorization — what a consumer may assume
|
||
|
||
- **A 30382 (or 30383/…) is meaningful to an observer only if its author is listed in the
|
||
observer's 10040 for a matching service type.** Quartz does not enforce this; the consuming
|
||
code does. The in-repo pattern is `commons/.../model/nip85TrustedAssertions/UserCardsCache.kt`:
|
||
`rankFlow(trustProviderList)` picks the received card whose **author pubkey equals the
|
||
provider entry's pubkey** and reads `rank()` from it. Assertions from unlisted signers are
|
||
simply ignored for trust purposes (they may still be stored; dropping them — as an external
|
||
store's orphan sweep does — is a legitimate storage policy, not a protocol rule).
|
||
- What an entry authorizes is scoped by its `ServiceType`: `30382:rank` authorizes that
|
||
provider's user-rank cards, nothing else. Amethyst models this as one provider slot per
|
||
metric (`liveUserRankProvider`, `liveUserFollowerCount` in
|
||
`amethyst/.../model/trustedAssertions/TrustProviderListState.kt`).
|
||
- **Multi-provider combination is unprescribed.** When two listed providers assert different
|
||
ranks, there is no spec'd merge; Amethyst avoids the question by selecting one provider per
|
||
metric slot. Consumers choose their own policy — document it.
|
||
- The relay URL in the entry is a **fetch hint, and it is honored**:
|
||
`amethyst/.../UserCardsSubAssembler.kt` subscribes for cards at the provider's declared relay
|
||
(`kinds=[30382], authors=[provider], #d=[targets]`).
|
||
|
||
### The dual use of kind 30382
|
||
|
||
The same kind serves two roles, distinguished **by author**:
|
||
|
||
1. **Provider WoT cards** — signed by a trust provider; public metric tags (`rank`,
|
||
`followers`, `hops`, …); this is what 10040 discovery points at.
|
||
2. **The account's own contact cards (nicknames, NIP-81-style)** — signed by the account,
|
||
one per target user. The petname, summary, and their NIP-30 emoji mappings **always live in
|
||
the NIP-44 encrypted content, never in public tags** (`UserAssertionEvent.build`/
|
||
`updatePetNameAndSummary` strip stray public copies; asserted by `ContactCardPetNameTest`).
|
||
`commons/.../ContactCardsState.kt` keys everything on `author == account` and ignores
|
||
provider cards.
|
||
|
||
## Tag vocabulary and value semantics
|
||
|
||
All tag classes share one shape: `TAG_NAME` + `parse(tag)` (null on wrong name/empty/non-numeric
|
||
value — a bad tag is *dropped*, never an error) + `assemble(value)` → `[name, value.toString()]`.
|
||
**A missing tag means "unknown" (`null` accessor), never zero.** There is deliberately no range
|
||
validation (rank isn't clamped, hours aren't checked against 0–23, counts may be negative) —
|
||
consumers must defend.
|
||
|
||
**On 30382** (`users/tags/`, accessors on `UserAssertionEvent` and as `TagArray` extensions in
|
||
`users/TagArrayExt.kt` so they also work on decrypted private arrays):
|
||
|
||
| Tag name | Accessor | Type | Semantics |
|
||
|---|---|---|---|
|
||
| `rank` | `rank()` | Int | Provider-relative score; higher is better. GrapeRank publishes `round(score × 100)` (so 0–100 in practice), but nothing enforces a scale — treat it as comparable only *within one provider*. |
|
||
| `followers` | `followerCount()` | Int | Follower count as the provider computes it (cumulative, provider-defined). |
|
||
| `hops` | `hops()` | Int | Shortest follow-path length **from the observer the provider computed for** to the subject (1 = directly followed). Mirrors Brainstorm GrapeRank's `hops`. The only tag with KDoc. |
|
||
| `first_created_at` | `firstCreatedAt()` | Long | Unix seconds of subject's earliest known event. |
|
||
| `post_cnt` / `reply_cnt` / `reactions_cnt` | `postCount()` etc. | Int | Activity counts. |
|
||
| `zap_amt_recd` / `zap_amt_sent` | `zapAmountReceived()`/`…Sent()` | Long | Sats. |
|
||
| `zap_cnt_recd` / `zap_cnt_sent` | `zapCountReceived()`/`…Sent()` | Int | Counts. |
|
||
| `zap_avg_amt_day_recd` / `zap_avg_amt_day_sent` | `zapAvgAmountDay…()` | Long | Sats/day averages. |
|
||
| `reports_cnt_recd` / `reports_cnt_sent` | `reportsCount…()` | Int | NIP-56 report counts. |
|
||
| `t` (repeatable) | `topics()` | List\<String> | Subject's topics/interests. |
|
||
| `active_hours_start` / `active_hours_end` | `activeHours…()` | Int | Hour-of-day; **no timezone is specified in code** — treat as provider-defined (UTC in practice) and unclamped. |
|
||
| `petname` / `summary` | `petName()`/`summary()` | String | Nickname fields — conventionally private (see dual use above). |
|
||
|
||
**On 30383/30384** (`tags/`, shared): `rank`, `comment_cnt`, `quote_cnt`, `repost_cnt`,
|
||
`reaction_cnt`, `zap_cnt` (Int) and `zap_amount` (Long, sats).
|
||
**On 30385**: only `rank`, `comment_cnt`, `reaction_cnt`.
|
||
|
||
## Building and parsing (use the typed helpers, not raw `arrayOf`)
|
||
|
||
```kotlin
|
||
// Provider list: declare a rank provider (this is what `amy graperank register` does)
|
||
val tag = ServiceProviderTag(ProviderTypes.rank, providerPubkeyHex, relayUrl)
|
||
val list = TrustProviderListEvent.create(tag, isPrivate = false, signer)
|
||
// or append to an existing one:
|
||
val updated = TrustProviderListEvent.add(existing, tag, isPrivate = false, signer)
|
||
val providers: List<ServiceProviderTag> = updated.serviceProviders() // public
|
||
val private = updated.privateTags(signer)?.serviceProviders() // private side
|
||
|
||
// Provider-style contact card (public metrics) — the GrapeRankPublisher pattern:
|
||
val card = UserAssertionEvent.create(
|
||
targetUser = subjectPubkey,
|
||
signer = providerSigner,
|
||
publicInitializer = {
|
||
rank(87)
|
||
followers(1234)
|
||
hops(2)
|
||
},
|
||
)
|
||
card.aboutUser() // d-tag → subject pubkey
|
||
card.rank() // 87
|
||
|
||
// Event assertion: unsigned template only (30383/84/85 have build(), no create())
|
||
val template = EventAssertionEvent.build(targetEventId) {
|
||
rank(12)
|
||
reactionCount(40)
|
||
zapAmount(2100)
|
||
}
|
||
val signed = signer.sign(template)
|
||
```
|
||
|
||
## Worked end-to-end example
|
||
|
||
Observer `O` trusts provider `P` for user ranks (kind 10040, replaceable, by `O`):
|
||
|
||
```json
|
||
{ "kind": 10040, "pubkey": "<O>",
|
||
"tags": [
|
||
["30382:rank", "<P>", "wss://nip85.brainstorm.world"],
|
||
["30382:followers", "<P>", "wss://nip85.brainstorm.world"]
|
||
],
|
||
"content": "" }
|
||
```
|
||
|
||
Provider `P` asserts about subject `S` (kind 30382, addressable at `30382:<P>:<S>`):
|
||
|
||
```json
|
||
{ "kind": 30382, "pubkey": "<P>",
|
||
"tags": [
|
||
["d", "<S>"],
|
||
["rank", "87"], ["followers", "1234"], ["hops", "2"]
|
||
],
|
||
"content": "" }
|
||
```
|
||
|
||
`P` asserts about an event `E` (kind 30383, addressable at `30383:<P>:<E>`):
|
||
|
||
```json
|
||
{ "kind": 30383, "pubkey": "<P>",
|
||
"tags": [["d", "<E>"], ["rank", "12"], ["reaction_cnt", "40"], ["zap_amount", "2100"]],
|
||
"content": "" }
|
||
```
|
||
|
||
Consumption chain: read `O`'s 10040 → entry matching `ServiceType(30382, "rank")` → subscribe
|
||
`{kinds:[30382], authors:["<P>"], "#d":["<S>", …]}` at the hinted relay → newest card per
|
||
address wins → `rank()`.
|
||
|
||
Literal fixtures: `quartz/src/commonTest/.../nip85TrustedAssertions/ServiceParser.kt` (a real
|
||
10040 — fiatjaf's, pointing at the Brainstorm provider) and `AssertionEventTest.kt` (all four
|
||
assertion kinds with every tag populated).
|
||
|
||
## Freshness / supersession
|
||
|
||
Assertions are addressable: **latest per `(kind, author, d-tag)` wins**; there is no expiry tag
|
||
convention and **no prescribed refresh cadence** — staleness policy is the consumer's.
|
||
Writers should avoid churn: `GrapeRankPublisher` re-signs a card only when
|
||
`(rank, followers, hops)` actually changed, and retracts with a NIP-09 kind-5 carrying the
|
||
card's `a`-tag (`30382:<provider>:<target>`).
|
||
|
||
## Stability notes (as of 2026-08)
|
||
|
||
- **Settled** (shipped consumers on both ends): the kind map; `ServiceProviderTag` entry shape;
|
||
`rank`/`followers`/`hops` on 30382; petname/summary-in-encrypted-content; 10040 relay-hint
|
||
consumption.
|
||
- **Written but lightly consumed** (parse, but gate ranking features carefully): the activity/
|
||
zap/report count tags, `active_hours_*` (no timezone semantics), 30383/30384/30385 (builders +
|
||
tests exist; no in-repo publisher yet).
|
||
- **Known warts**: `ServiceProviderTag.assemble(id: ServiceProviderTag)` infers `Array<Any>` —
|
||
dead code, don't use it; `SummaryTag.assemble(ip:)`/`ActiveHours*Tag.assemble(count:)` params
|
||
are misnamed; the tests live under `commonTest/.../experimental/nip85TrustedAssertions/`
|
||
(stale path); `TrustProviderListEvent` extends the addressable base, so a stray on-wire `d`
|
||
tag is reflected by `dTag()` even though the convention is `""`.
|
||
|
||
## Where it's consumed (reading list)
|
||
|
||
- **Publisher**: `quartz/.../experimental/graperank/GrapeRankPublisher.kt` (canonical 30382
|
||
writer), `cli/.../graperank/` (`amy graperank register|unregister|providers|publish`).
|
||
- **Client model**: `commons/.../model/nip85TrustedAssertions/` (`ContactCardsState`,
|
||
`UserCardsCache`, `ContactCardDecryptionCache`, `TrustProviderListDecryptionCache`),
|
||
`amethyst/.../model/trustedAssertions/TrustProviderListState.kt`.
|
||
- **Relay plumbing**: `commons/.../relayClient/assemblers/ContactCardFilters.kt`,
|
||
`amethyst/.../reqCommand/user/watchers/UserCardsSubAssembler.kt`.
|