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) uses
FIXED_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 is
commons/.../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 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)
// 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-wiredtag 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.