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 FIXEDDTAG = "". 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 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:firstcreatedat, 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:
- 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/ 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 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. |
firstcreatedat |
firstCreatedAt() |
Long |
Unix seconds of subject's earliest known event. |
postcnt / replycnt / reactions_cnt |
postCount() etc. |
Int |
Activity counts. |
zapamtrecd / zapamtsent |
zapAmountReceived()/…Sent() |
Long |
Sats. |
zapcntrecd / zapcntsent |
zapCountReceived()/…Sent() |
Int |
Counts. |
zapavgamtdayrecd / zapavgamtdaysent |
zapAvgAmountDay…() |
Long |
Sats/day averages. |
reportscntrecd / reportscntsent |
reportsCount…() |
Int |
NIP-56 report counts. |
t (repeatable) |
topics() |
List\<String> |
Subject's topics/interests. |
activehoursstart / activehoursend |
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, commentcnt, quotecnt, repostcnt, reactioncnt, zapcnt (Int) and zapamount (Long, sats). On 30385: only rank, commentcnt, reactioncnt.
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;
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, activehours* (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.