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.
Install
npx skillscat add vitorpamplona/amethyst/nip85-trusted-assertions Install via the SkillsCat registry.
This skill provides a Kotlin implementation of the NIP-85 trusted assertions model used by Amethyst-family software, handling kinds 10040, 30382, 30383, 30384, and 30385 for trust provider lists and user/event/addressable/external-id assertions with typed tags. It solves the problem of consistently building, parsing, and consuming these evolving NIP-85 events across clients. Developers should use it when implementing or integrating with NIP-85 trust assertion systems in Kotlin-based Nostr applications.
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/ContactCardEvent |
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: ContactCardEvent.createAddress(owner, target) → Address(30382, owner, target)
(owner = signer, target = subject). TrustProviderListEvent.createAddress(pubKey) usesFIXED_D_TAG = "". AssertionEventTest.eventKindsAreCorrect pins all five numbers.
ContactCardEvent 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.
["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 intype.ServiceType.isOfKindis the
allocation-free prefix check.ServiceProviderTag.parserequires ≥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 inServiceTypeParserTest).- Entries may be public (tag array) or private (NIP-44 content);
create/addtakeisPrivate.removealways 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-metric30383:*/30384:*/30385:*, etc. It is an open vocabulary: real 10040s in the wild (see the fiatjaf →
brainstorm fixture incommonTest/.../nip85TrustedAssertions/ServiceParser.kt) carry types
Quartz doesn't enumerate (30382:personalizedGrapeRank_influence,30382:hops,30382:verifiedFollowersCount, …). Parse anykind: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 iscommons/.../model/nip85TrustedAssertions/UserCardsCache.kt:rankFlow(trustProviderList)picks the received card whose author pubkey equals the
provider entry's pubkey and readsrank()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:rankauthorizes that
provider's user-rank cards, nothing else. Amethyst models this as one provider slot per
metric (liveUserRankProvider,liveUserFollowerCountinamethyst/.../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.ktsubscribes 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:
- Provider WoT cards — signed by a trust provider; public metric tags (
rank,followers,hops, …); this is what 10040 discovery points at. - 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 (ContactCardEvent.build/updatePetNameAndSummarystrip stray public copies; asserted byContactCardPetNameTest).commons/.../ContactCardsState.ktkeys everything onauthor == accountand 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 ContactCardEvent and as TagArray extensions inusers/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)
// 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 = ContactCardEvent.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):
{ "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>):
{ "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>):
{ "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;
ServiceProviderTagentry shape;rank/followers/hopson 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)infersArray<Any>—
dead code, don't use it;SummaryTag.assemble(ip:)/ActiveHours*Tag.assemble(count:)params
are misnamed; the tests live undercommonTest/.../experimental/nip85TrustedAssertions/
(stale path);TrustProviderListEventextends the addressable base, so a stray on-wired
tag is reflected bydTag()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.