fix(quartz): audit fixes and hot-path tuning for Decentralized Lists

Bugs, each reproduced by a failing test in AuditRegressionTest first:

- Event taggings could not federate: parse took a single namespace and
  read any other `nostr-event-tag` z as a tagging header. Every tagging
  builder now takes a collection of concept namespaces and every parser
  the set the reader honors (single-namespace overloads kept).
- HeaderResolution picked whichever version of a header came first
  instead of the newest.
- `b` tags accepted any string as a target; the value form is closed,
  so only coordinates are read now.
- ItemKindTag.isTag accepted negative kinds that parse rejected.
- An explicit null in word-section lists made the whole section
  unreadable.

Performance:

- The NIP-50 read path walks the tags in two allocation-free passes
  instead of one full scan and one parsed object per field.
- Hint providers are single pass, and z coordinates skip the address
  parser; a shared CoordinateShape check also keeps plain list names
  (which may contain colons) from logging parser warnings.
- Single-value readers use the iterator-free fast* tag operators.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A2NXYQuDhvx4cmH1Nq5Zxq
This commit is contained in:
Claude
2026-09-25 16:24:27 +00:00
parent 20920efb3e
commit c84e08cd32
21 changed files with 401 additions and 81 deletions
@@ -0,0 +1,45 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists
/**
* A cheap, allocation-free check that a value has the `<kind>:<64-hex pubkey>:<d>` coordinate
* shape, done before anything reaches `AddressSerializer.parse`. That parser splits the string
* and logs a warning for every value it rejects — and in this family a rejected value is
* routine: `z` tags legitimately carry plain list names, which may be long and contain colons.
*/
internal object CoordinateShape {
fun matches(value: String): Boolean {
val firstColon = value.indexOf(':')
// kinds are at most 5 digits (0..65535)
if (firstColon !in 1..5) return false
for (i in 0 until firstColon) {
if (value[i] !in '0'..'9') return false
}
val pubKeyEnd = firstColon + 1 + 64
if (value.length <= pubKeyEnd || value[pubKeyEnd] != ':') return false
for (i in firstColon + 1 until pubKeyEnd) {
val c = value[i]
if (c !in '0'..'9' && c !in 'a'..'f' && c !in 'A'..'F') return false
}
return true
}
}
@@ -128,6 +128,10 @@ All kind 39999 items, told apart by their `z` tags:
NIP-09 deletion.
- `TaggingHeader` + `EventTagging`: tagging events. The target sits in
`a`/`e`, so the tag is reached through a `z` to a per-tag header.
- Federation: every builder takes a collection of concept namespaces and
writes one concept `z` per namespace; every parser takes the set of
namespaces the reader honors. Pass them all: a concept `z` the reader didn't
list would be taken for something else, e.g. a tagging header.
- `polarity()`: no tag means apply, `≥ 0.5` applied, `≤ -0.5` disputed, and
anything in between is not counted in v1.
@@ -20,11 +20,13 @@
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.names
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.titles
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.comments
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.name
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.title
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.NamesTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.SingularPlural
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TitlesTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.CommentsTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.NameTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.TitleTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.DescriptionTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
@@ -42,18 +44,40 @@ import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor
* @return false when the visitor stopped the walk.
*/
fun TagArray.forEachSearchableListField(visitor: IndexableFieldVisitor): Boolean {
names()?.let {
if (!visitor.visit(it.singular)) return false
if (!visitor.visit(it.plural)) return false
// The read path runs once per event per keystroke, so this is two allocation-free passes
// instead of one full scan (and one parsed object) per field. The first pass only remembers
// the first well-formed tag of each field; the fixed visiting order is applied afterwards.
var names: Array<String>? = null
var titles: Array<String>? = null
var name: String? = null
var title: String? = null
var description: String? = null
var comments: String? = null
fastForEach { tag ->
if (tag.size < 2 || tag[1].isEmpty()) return@fastForEach
when (tag[0]) {
NamesTag.TAG_NAME -> if (names == null && SingularPlural.isTag(tag, NamesTag.TAG_NAME)) names = tag
TitlesTag.TAG_NAME -> if (titles == null && SingularPlural.isTag(tag, TitlesTag.TAG_NAME)) titles = tag
NameTag.TAG_NAME -> if (name == null) name = tag[1]
TitleTag.TAG_NAME -> if (title == null) title = tag[1]
DescriptionTag.TAG_NAME -> if (description == null) description = tag[1]
CommentsTag.TAG_NAME -> if (comments == null) comments = tag[1]
}
}
titles()?.let {
if (!visitor.visit(it.singular)) return false
if (!visitor.visit(it.plural)) return false
names?.let {
if (!visitor.visit(it[1])) return false
if (!visitor.visit(it[2])) return false
}
name()?.let { if (!visitor.visit(it)) return false }
title()?.let { if (!visitor.visit(it)) return false }
description()?.let { if (!visitor.visit(it)) return false }
comments()?.let { if (!visitor.visit(it)) return false }
titles?.let {
if (!visitor.visit(it[1])) return false
if (!visitor.visit(it[2])) return false
}
name?.let { if (!visitor.visit(it)) return false }
title?.let { if (!visitor.visit(it)) return false }
description?.let { if (!visitor.visit(it)) return false }
comments?.let { if (!visitor.visit(it)) return false }
fastForEach { tag ->
HashtagTag.parse(tag)?.let { if (!visitor.visit(it)) return false }
}
@@ -26,8 +26,10 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritFrom
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritType
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.JsonTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
fun TagArray.description() = firstNotNullOfOrNull(DescriptionTag::parse)
fun TagArray.description() = fastFirstNotNullOfOrNull(DescriptionTag::parse)
/** Every `b` link, in tag order. Excludes the `b-tag-deferred` marker. */
fun TagArray.inheritFrom() = mapNotNull(InheritFromTag::parse)
@@ -39,10 +41,10 @@ fun TagArray.inheritFrom() = mapNotNull(InheritFromTag::parse)
fun TagArray.inheritFromTargets(type: InheritType) = mapNotNull { InheritFromTag.parseTarget(it, type) }
/** True when the event carries `["b", "b-tag-deferred"]`: deliberately affiliated with nothing. */
fun TagArray.isDeliberatelyUnaffiliated() = any(InheritFromTag::isUnaffiliatedMarker)
fun TagArray.isDeliberatelyUnaffiliated() = fastAny(InheritFromTag::isUnaffiliatedMarker)
/** The raw `json` tag. */
fun TagArray.json() = firstNotNullOfOrNull(JsonTag::parse)
fun TagArray.json() = fastFirstNotNullOfOrNull(JsonTag::parse)
/** The `json` tag parsed as a JSON object, or null when absent or malformed. */
fun TagArray.wordWrapper() = json()?.let(WordWrapper::parse)
@@ -61,6 +61,7 @@ object HeaderResolution {
headers: Collection<AddressableListHeaderEvent>,
): AddressableListHeaderEvent? =
candidates(user, slug, treasureMap).firstNotNullOfOrNull { candidate ->
headers.firstOrNull { it.pubKey == candidate.pubKeyHex && it.dTag() == candidate.dTag }
// several versions of one addressable header may be at hand: the newest is the header
headers.filter { it.pubKey == candidate.pubKeyHex && it.dTag() == candidate.dTag }.maxByOrNull { it.createdAt }
}
}
@@ -25,12 +25,13 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.tags.A
import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.tags.DListCuration
import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.tags.DListCurationTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
// Both entry families ride NIP-85's kind 10040 but are bounded away from it: NIP-85 only reads
// 30382-30385 keys, and these parsers only read 39998/39999 ones.
/** The blanket assistant designation. On duplicates the first occurrence wins. */
fun TagArray.dListAssistant() = firstNotNullOfOrNull(AssistantDesignationTag::parse)
fun TagArray.dListAssistant() = fastFirstNotNullOfOrNull(AssistantDesignationTag::parse)
/** Every per-list curation entry, first occurrence per (kind, d-tag). */
fun TagArray.dListCurations() = mapNotNull(DListCurationTag::parse).distinctBy { it.kind to it.dTag }
@@ -38,7 +39,7 @@ fun TagArray.dListCurations() = mapNotNull(DListCurationTag::parse).distinctBy {
fun TagArray.dListCuration(
kind: Int,
dTag: String,
) = firstNotNullOfOrNull { tag -> DListCurationTag.parse(tag)?.takeIf { it.kind == kind && it.dTag == dTag } }
) = fastFirstNotNullOfOrNull { tag -> DListCurationTag.parse(tag)?.takeIf { it.kind == kind && it.dTag == dTag } }
/**
* Sets the blanket designation in place, keeping every other tag verbatim — 10040 is
@@ -73,10 +73,16 @@ data class WordSection(
val slug: String? = null,
val name: String? = null,
val title: String? = null,
val wordTypes: List<String> = emptyList(),
// Nullable: this is authored JSON, and an explicit `null` must read as "absent" rather
// than fail the whole section. Use [types] / [memberOf] for the non-null view.
val wordTypes: List<String>? = null,
/** Set on a concept's core nodes, pointing back at the concept; omitted by the header itself. */
val coreMemberOf: List<NodeRef> = emptyList(),
)
val coreMemberOf: List<NodeRef>? = null,
) {
fun types() = wordTypes.orEmpty()
fun memberOf() = coreMemberOf.orEmpty()
}
@Immutable
@Serializable
@@ -28,12 +28,14 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagR
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TagRuleType
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.TitlesTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastAny
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
fun TagArray.names() = firstNotNullOfOrNull(NamesTag::parse)
fun TagArray.names() = fastFirstNotNullOfOrNull(NamesTag::parse)
fun TagArray.titles() = firstNotNullOfOrNull(TitlesTag::parse)
fun TagArray.titles() = fastFirstNotNullOfOrNull(TitlesTag::parse)
fun TagArray.slugs() = firstNotNullOfOrNull(SlugsTag::parse)
fun TagArray.slugs() = fastFirstNotNullOfOrNull(SlugsTag::parse)
/** Every `required` / `allowed` / `recommended` / `disallowed` rule, in tag order. */
fun TagArray.tagRules() = mapNotNull(TagRuleTag::parse)
@@ -52,10 +54,10 @@ fun TagArray.disallowedTags() = tagRuleNames(TagRuleType.DISALLOWED)
* True when the tags carry a list declaration. Standard headers (9998/39998) must; an item
* (9999/39999) that does is using the spec's "nonstandard" method to declare a list.
*/
fun TagArray.declaresList() = any(NamesTag::isTag)
fun TagArray.declaresList() = fastAny(NamesTag::isTag)
/** The `concept-graph` pointer when present. See [ConceptGraphTag.compute] for the fallback. */
fun TagArray.conceptGraph() = firstNotNullOfOrNull(ConceptGraphTag::parse)
fun TagArray.conceptGraph() = fastFirstNotNullOfOrNull(ConceptGraphTag::parse)
/** The `item-kind` declarations, in tag order. */
fun TagArray.itemKinds() = mapNotNull(ItemKindTag::parse)
@@ -44,7 +44,7 @@ class ItemKindTag {
companion object {
const val TAG_NAME = "item-kind"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].toIntOrNull() != null
fun isTag(tag: Array<String>) = parse(tag) != null
fun parse(tag: Array<String>): ItemKind? {
ensure(tag.has(1)) { return null }
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.wordWrapper
import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
@@ -89,13 +90,21 @@ class AddressableListItemEvent(
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint)
/** Event items plus any parent lists referenced by id. */
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(ParentListTag::parseEventId)
/** Event items plus any parent lists referenced by id, in one pass. */
override fun linkedEventIds(): List<HexKey> {
val out = ArrayList<HexKey>()
tags.fastForEach { tag -> (ETag.parseId(tag) ?: ParentListTag.parseEventId(tag))?.let { out.add(it) } }
return out
}
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
/** Addressable items plus any parent lists referenced by coordinate. */
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseValidAddress) + tags.mapNotNull { ParentListTag.parseAddress(it)?.toValue() }
/** Addressable items plus any parent lists referenced by coordinate, in one pass. */
override fun linkedAddressIds(): List<String> {
val out = ArrayList<String>()
tags.fastForEach { tag -> (ATag.parseValidAddress(tag) ?: ParentListTag.parseCoordinate(tag))?.let { out.add(it) } }
return out
}
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
@@ -42,6 +42,7 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.searchableListCo
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
@@ -83,13 +84,21 @@ class ListItemEvent(
override fun eventHints() = tags.mapNotNull(ETag::parseAsHint)
/** Event items plus any parent lists referenced by id. */
override fun linkedEventIds() = tags.mapNotNull(ETag::parseId) + tags.mapNotNull(ParentListTag::parseEventId)
/** Event items plus any parent lists referenced by id, in one pass. */
override fun linkedEventIds(): List<HexKey> {
val out = ArrayList<HexKey>()
tags.fastForEach { tag -> (ETag.parseId(tag) ?: ParentListTag.parseEventId(tag))?.let { out.add(it) } }
return out
}
override fun addressHints() = tags.mapNotNull(ATag::parseAsHint)
/** Addressable items plus any parent lists referenced by coordinate. */
override fun linkedAddressIds() = tags.mapNotNull(ATag::parseValidAddress) + tags.mapNotNull { ParentListTag.parseAddress(it)?.toValue() }
/** Addressable items plus any parent lists referenced by coordinate, in one pass. */
override fun linkedAddressIds(): List<String> {
val out = ArrayList<String>()
tags.fastForEach { tag -> (ATag.parseValidAddress(tag) ?: ParentListTag.parseCoordinate(tag))?.let { out.add(it) } }
return out
}
override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint)
@@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.SlugTa
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.SubsetOfTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.TitleTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.hashtags.HashtagTag
@@ -39,13 +40,13 @@ fun TagArray.parentLists() = mapNotNull(ParentListTag::parse)
/** The raw `z` values, as they would go into a `#z` filter. */
fun TagArray.parentListPointers() = mapNotNull(ParentListTag::parseValue)
fun TagArray.name() = firstNotNullOfOrNull(NameTag::parse)
fun TagArray.name() = fastFirstNotNullOfOrNull(NameTag::parse)
fun TagArray.title() = firstNotNullOfOrNull(TitleTag::parse)
fun TagArray.title() = fastFirstNotNullOfOrNull(TitleTag::parse)
fun TagArray.slug() = firstNotNullOfOrNull(SlugTag::parse)
fun TagArray.slug() = fastFirstNotNullOfOrNull(SlugTag::parse)
fun TagArray.comments() = firstNotNullOfOrNull(CommentsTag::parse)
fun TagArray.comments() = fastFirstNotNullOfOrNull(CommentsTag::parse)
/** Pubkeys declared as items (`p`). */
fun TagArray.itemPubKeys() = mapNotNull(PTag::parse)
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decentralizedLists.CoordinateShape
import com.vitorpamplona.quartz.experimental.decentralizedLists.DecentralizedListEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer
@@ -101,6 +102,12 @@ class ParentListTag {
return if (looksLikeAddress(value)) AddressSerializer.parse(value) else null
}
/** The raw `kind:pubkey:d` value when [tag] points at a coordinate. No parsing, no allocation. */
fun parseCoordinate(tag: Array<String>): String? {
val value = parseValue(tag) ?: return null
return if (looksLikeAddress(value)) value else null
}
fun classify(value: String): ParentList {
if (isEventId(value)) return ParentList.EventId(value)
if (looksLikeAddress(value)) {
@@ -113,7 +120,7 @@ class ParentListTag {
// Only coordinate-shaped values reach the address parser: it logs a warning for
// everything it rejects, and a plain list name such as "dog" is not an error.
private fun looksLikeAddress(value: String) = value.length >= 66 && value.contains(':')
private fun looksLikeAddress(value: String) = CoordinateShape.matches(value)
fun assemble(pointer: String) = arrayOf(TAG_NAME, pointer)
@@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.Parent
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Polarity
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -71,21 +72,31 @@ object TaggingHeader {
* @param taggingWithSpecificTagConcept the deployment's `tagging-with-specific-tag` concept.
*/
fun build(
taggingWithSpecificTagConcept: String,
taggingWithSpecificTagConcepts: Collection<String>,
tagElement: Address,
singularName: String,
pluralName: String,
description: String? = null,
createdAt: Long = TimeUtils.now(),
) = AddressableListItemEvent.build(
parent = ParentListTag.classify(taggingWithSpecificTagConcept),
parent = taggingWithSpecificTagConcepts.firstNamespace(),
dTag = dTag(tagElement.dTag),
createdAt = createdAt,
) {
conceptNamespaces(taggingWithSpecificTagConcepts)
names(singularName, pluralName)
description?.let { this.description(it) }
itemAddress(tagElement)
}
fun build(
taggingWithSpecificTagConcept: String,
tagElement: Address,
singularName: String,
pluralName: String,
description: String? = null,
createdAt: Long = TimeUtils.now(),
) = build(listOf(taggingWithSpecificTagConcept), tagElement, singularName, pluralName, description, createdAt)
}
/**
@@ -118,13 +129,14 @@ data class EventTagging(
) = "event-tag-$tagSlug-${target.prefix}-${asserter.take(8)}"
/**
* @param nostrEventTagConcept the deployment's `nostr-event-tag` concept address.
* @param nostrEventTagConcepts the `nostr-event-tag` concept of each authority namespace
* to join: the deployment's own, plus any shared one it federates with.
* @param taggingHeader the per-tag tagging header's coordinate.
* @param tagSlug the applied tag's slug, for the deterministic `d`.
* @param asserter the pubkey that will sign, for the deterministic `d`.
*/
fun build(
nostrEventTagConcept: String,
nostrEventTagConcepts: Collection<String>,
taggingHeader: Address,
tagSlug: String,
target: TaggingTarget,
@@ -132,10 +144,11 @@ data class EventTagging(
apply: Boolean = true,
createdAt: Long = TimeUtils.now(),
) = AddressableListItemEvent.build(
parent = ParentListTag.classify(nostrEventTagConcept),
parent = nostrEventTagConcepts.firstNamespace(),
dTag = dTag(tagSlug, target, asserter),
createdAt = createdAt,
) {
conceptNamespaces(nostrEventTagConcepts)
parentList(ParentListTag.classify(taggingHeader.toValue()))
when (target) {
is TaggingTarget.ByAddress -> itemAddress(target.address)
@@ -144,22 +157,42 @@ data class EventTagging(
polarity(apply)
}
/** Null unless [event] joins [nostrEventTagConcept], names a tagging header and a target. */
fun build(
nostrEventTagConcept: String,
taggingHeader: Address,
tagSlug: String,
target: TaggingTarget,
asserter: HexKey,
apply: Boolean = true,
createdAt: Long = TimeUtils.now(),
) = build(listOf(nostrEventTagConcept), taggingHeader, tagSlug, target, asserter, apply, createdAt)
/**
* Null unless [event] joins one of the [honoredNamespaces]' `nostr-event-tag` concepts,
* names a tagging header and a target. Every namespace the reader honors must be passed:
* a federated tagging carries one concept `z` per namespace it joined, and any concept
* `z` the reader did not list would otherwise be taken for a tagging header.
*/
fun parse(
event: AddressableListItemEvent,
nostrEventTagConcept: String,
honoredNamespaces: Set<String>,
): EventTagging? {
val pointers = event.parentListPointers()
if (nostrEventTagConcept !in pointers) return null
val headers = pointers.filter { it != nostrEventTagConcept }
if (pointers.none { it in honoredNamespaces }) return null
val headers = pointers.filter { it !in honoredNamespaces }
if (headers.isEmpty()) return null
val target =
event.tags.firstNotNullOfOrNull(ATag::parseAddress)?.let { TaggingTarget.ByAddress(it) }
?: event.tags.firstNotNullOfOrNull(ETag::parseId)?.let { TaggingTarget.ByEventId(it) }
event.tags.fastFirstNotNullOfOrNull(ATag::parseAddress)?.let { TaggingTarget.ByAddress(it) }
?: event.tags.fastFirstNotNullOfOrNull(ETag::parseId)?.let { TaggingTarget.ByEventId(it) }
?: return null
return EventTagging(target, headers, event.tags.polarity())
}
fun parse(
event: AddressableListItemEvent,
nostrEventTagConcept: String,
) = parse(event, setOf(nostrEventTagConcept))
}
}
@@ -25,11 +25,11 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.item.Addressable
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemAddress
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemPubKey
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Polarity
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
@@ -81,11 +81,11 @@ data class PubKeyTagging(
) = "profile-tag-$tagSlug-${target.take(8)}-${asserter.take(8)}"
/**
* @param userTagConcept the deployment's `nostr-user-tag` concept address.
* @param userTagConcepts the `nostr-user-tag` concept of each authority namespace to join.
* @param asserter the pubkey that will sign, needed for the deterministic `d`.
*/
fun build(
userTagConcept: String,
userTagConcepts: Collection<String>,
tagElement: Address,
tagEventId: HexKey?,
target: HexKey,
@@ -93,11 +93,12 @@ data class PubKeyTagging(
apply: Boolean = true,
createdAt: Long = TimeUtils.now(),
) = AddressableListItemEvent.build(
parent = ParentListTag.classify(userTagConcept),
parent = userTagConcepts.firstNamespace(),
dTag = dTag(tagElement.dTag, target, asserter),
createdAt = createdAt,
content = JsonMapper.toJson(NostrUserTagContent(NostrUserTagInfo(target, tagEventId))),
) {
conceptNamespaces(userTagConcepts)
itemPubKey(target)
itemAddress(tagElement)
tagEventId?.let { itemEvent(it) }
@@ -111,21 +112,36 @@ data class PubKeyTagging(
asserter: HexKey,
apply: Boolean = true,
createdAt: Long = TimeUtils.now(),
) = build(userTagConcept, tagElement.address(), tagElement.id, target, asserter, apply, createdAt)
) = build(listOf(userTagConcept), tagElement.address(), tagElement.id, target, asserter, apply, createdAt)
/** Null unless [event] joins [userTagConcept] and names a target pubkey. */
fun build(
userTagConcept: String,
tagElement: Address,
tagEventId: HexKey?,
target: HexKey,
asserter: HexKey,
apply: Boolean = true,
createdAt: Long = TimeUtils.now(),
) = build(listOf(userTagConcept), tagElement, tagEventId, target, asserter, apply, createdAt)
/** Null unless [event] joins one of the [honoredNamespaces] and names a target pubkey. */
fun parse(
event: AddressableListItemEvent,
userTagConcept: String,
honoredNamespaces: Set<String>,
): PubKeyTagging? {
if (userTagConcept !in event.parentListPointers()) return null
val target = event.tags.firstNotNullOfOrNull(PTag::parseKey) ?: return null
if (event.parentListPointers().none { it in honoredNamespaces }) return null
val target = event.tags.fastFirstNotNullOfOrNull(PTag::parseKey) ?: return null
return PubKeyTagging(
target = target,
tag = event.tags.firstNotNullOfOrNull(ATag::parseAddress),
tagEventId = event.tags.firstNotNullOfOrNull(ETag::parseId),
tag = event.tags.fastFirstNotNullOfOrNull(ATag::parseAddress),
tagEventId = event.tags.fastFirstNotNullOfOrNull(ETag::parseId),
polarity = event.tags.polarity(),
)
}
fun parse(
event: AddressableListItemEvent,
userTagConcept: String,
) = parse(event, setOf(userTagConcept))
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.experimental.decentralizedLists.taggings
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.CurationMethod
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.CurationMethodTag
@@ -33,3 +34,15 @@ fun TagArrayBuilder<AddressableListItemEvent>.curationMethod(method: CurationMet
/** Rides alongside the concept-membership `z`, never instead of it. */
fun TagArrayBuilder<AddressableListItemEvent>.applicabilityHint(hint: TagApplicabilityHint) = addUniqueValueIfNew(ParentListTag.assemble(hint.code))
/**
* Federation: one concept `z` per authority namespace the publisher joins (e.g. a shared
* canonical `nostr-event-tag` and the deployment's own). Joining several is opt-in; a reader
* scanning any one of them finds the event.
*/
fun TagArrayBuilder<AddressableListItemEvent>.conceptNamespaces(concepts: Collection<String>) = concepts.forEach { addUniqueValueIfNew(ParentListTag.assemble(it)) }
internal fun Collection<String>.firstNamespace(): ParentList {
require(isNotEmpty()) { "At least one concept namespace is required" }
return ParentListTag.classify(first())
}
@@ -25,18 +25,20 @@ import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Cu
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.Polarity
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.PolarityTag
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip01Core.core.fastFirstOrNull
/**
* How a v1 reader counts this tagging. No `polarity` tag means apply; a tag whose value is
* not a number is not counted, rather than guessed.
*/
fun TagArray.polarity(): Polarity {
val tag = firstOrNull(PolarityTag::isTag) ?: return Polarity.APPLIED
val tag = fastFirstOrNull(PolarityTag::isTag) ?: return Polarity.APPLIED
val value = PolarityTag.parseValue(tag) ?: return Polarity.UNCOUNTED
return PolarityTag.bucket(value)
}
fun TagArray.curationMethod() = firstNotNullOfOrNull(CurationMethodTag::parse)
fun TagArray.curationMethod() = fastFirstNotNullOfOrNull(CurationMethodTag::parse)
/** The applicability hints a tag-element's author recorded. Hints, never gates. */
fun TagArray.applicabilityHints() = parentListPointers().mapNotNull(TagApplicabilityHint::fromCode)
@@ -22,7 +22,6 @@ package com.vitorpamplona.quartz.experimental.decentralizedLists.taggings
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.serialization.Serializable
@@ -79,20 +78,30 @@ object TagElement {
* Deployment-specific: never hardcode it.
*/
fun build(
tagConcept: String,
tagConcepts: Collection<String>,
slug: String,
name: String,
description: String? = null,
hints: Set<TagApplicabilityHint> = emptySet(),
createdAt: Long = TimeUtils.now(),
) = AddressableListItemEvent.build(
parent = ParentListTag.classify(tagConcept),
parent = tagConcepts.firstNamespace(),
dTag = slug,
createdAt = createdAt,
content = TagElementContent(TagInfo(slug, name, description)).toContent(),
) {
conceptNamespaces(tagConcepts)
hints.forEach { applicabilityHint(it) }
}
fun build(
tagConcept: String,
slug: String,
name: String,
description: String? = null,
hints: Set<TagApplicabilityHint> = emptySet(),
createdAt: Long = TimeUtils.now(),
) = build(listOf(tagConcept), slug, name, description, hints, createdAt)
fun content(event: AddressableListItemEvent) = TagElementContent.parse(event.content)
}
@@ -24,11 +24,11 @@ import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemAddress
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.itemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentListTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.tags.CurationMethod
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.fastFirstNotNullOfOrNull
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.events.ETag
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -79,33 +79,47 @@ data class TagPin(
* @param viewer the pubkey that will sign, needed for the deterministic `d`.
*/
fun build(
tagPinningConcept: String,
tagPinningConcepts: Collection<String>,
tagElement: AddressableListItemEvent,
viewer: HexKey,
curationMethod: CurationMethod,
createdAt: Long = TimeUtils.now(),
) = AddressableListItemEvent.build(
parent = ParentListTag.classify(tagPinningConcept),
parent = tagPinningConcepts.firstNamespace(),
dTag = dTag(tagElement.dTag(), tagElement.pubKey, viewer),
createdAt = createdAt,
content = JsonMapper.toJson(TagPinningContent(TagPinningInfo(tagElement.id, curationMethod))),
) {
conceptNamespaces(tagPinningConcepts)
itemEvent(tagElement.id)
itemAddress(tagElement.address())
curationMethod(curationMethod)
}
/** Null unless [event] joins [tagPinningConcept]. */
fun build(
tagPinningConcept: String,
tagElement: AddressableListItemEvent,
viewer: HexKey,
curationMethod: CurationMethod,
createdAt: Long = TimeUtils.now(),
) = build(listOf(tagPinningConcept), tagElement, viewer, curationMethod, createdAt)
/** Null unless [event] joins one of the [honoredNamespaces]. */
fun parse(
event: AddressableListItemEvent,
tagPinningConcept: String,
honoredNamespaces: Set<String>,
): TagPin? {
if (tagPinningConcept !in event.parentListPointers()) return null
if (event.parentListPointers().none { it in honoredNamespaces }) return null
return TagPin(
tag = event.tags.firstNotNullOfOrNull(ATag::parseAddress),
tagEventId = event.tags.firstNotNullOfOrNull(ETag::parseId),
tag = event.tags.fastFirstNotNullOfOrNull(ATag::parseAddress),
tagEventId = event.tags.fastFirstNotNullOfOrNull(ETag::parseId),
curationMethod = event.tags.curationMethod(),
)
}
fun parse(
event: AddressableListItemEvent,
tagPinningConcept: String,
) = parse(event, setOf(tagPinningConcept))
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.experimental.decentralizedLists.tags
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.experimental.decentralizedLists.CoordinateShape
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.has
import com.vitorpamplona.quartz.utils.ensure
@@ -82,15 +83,16 @@ data class InheritFromTag(
*/
const val UNAFFILIATED = "b-tag-deferred"
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[1] != UNAFFILIATED
fun isTag(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && CoordinateShape.matches(tag[1])
fun isUnaffiliatedMarker(tag: Array<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == UNAFFILIATED
fun parse(tag: Array<String>): InheritFromTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
ensure(tag[1].isNotEmpty()) { return null }
ensure(tag[1] != UNAFFILIATED) { return null }
// The value form is closed: a coordinate, or the reserved marker (which is not a
// target). Anything else derives nothing rather than becoming a bogus parent.
ensure(CoordinateShape.matches(tag[1])) { return null }
return InheritFromTag(tag[1], InheritType.fromCode(tag.getOrNull(2)))
}
@@ -0,0 +1,120 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.experimental.decentralizedLists
import com.vitorpamplona.quartz.experimental.decentralizedLists.assistant.HeaderResolution
import com.vitorpamplona.quartz.experimental.decentralizedLists.concepts.WordWrapper
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.AddressableListHeaderEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.itemKinds
import com.vitorpamplona.quartz.experimental.decentralizedLists.header.tags.ItemKindTag
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.AddressableListItemEvent
import com.vitorpamplona.quartz.experimental.decentralizedLists.item.tags.ParentList
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.EventTagging
import com.vitorpamplona.quartz.experimental.decentralizedLists.taggings.TaggingTarget
import com.vitorpamplona.quartz.experimental.decentralizedLists.tags.InheritType
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
/** Regressions found in the audit: each failed before its fix. */
class AuditRegressionTest {
private val alice = "a".repeat(64)
private val bob = "b".repeat(64)
private val ta = "d".repeat(64)
private val other = "f".repeat(64)
private fun item(tags: Array<Array<String>>) = assertIs<AddressableListItemEvent>(EventFactory.create("e".repeat(64), alice, 1L, 39999, tags, "", ""))
// Event Taggings § "Concept namespaces & federation": one nostr-event-tag `z` per namespace.
@Test
fun federatedTaggingsDoNotMistakeOtherNamespacesForTaggingHeaders() {
val tagging =
item(
arrayOf(
arrayOf("d", "event-tag-awesome-tag-cccccccc-aaaaaaaa"),
arrayOf("a", "39999:$bob:good-tag"),
arrayOf("z", "39998:$ta:nostr-event-tag"),
arrayOf("z", "39998:$other:nostr-event-tag"),
arrayOf("z", "39999:$bob:tagging:awesome-tag-tagging"),
),
)
val parsed = EventTagging.parse(tagging, setOf("39998:$ta:nostr-event-tag", "39998:$other:nostr-event-tag"))!!
assertEquals(listOf("39999:$bob:tagging:awesome-tag-tagging"), parsed.taggingHeaders)
}
@Test
fun federatedTaggingsCanBeBuiltForSeveralNamespaces() {
val template =
EventTagging.build(
nostrEventTagConcepts = listOf("39998:$ta:nostr-event-tag", "39998:$other:nostr-event-tag"),
taggingHeader = Address(39999, bob, "tagging:awesome-tag-tagging"),
tagSlug = "awesome-tag",
target = TaggingTarget.ByEventId("1".repeat(64)),
asserter = alice,
)
assertEquals(
listOf("39998:$ta:nostr-event-tag", "39998:$other:nostr-event-tag", "39999:$bob:tagging:awesome-tag-tagging"),
template.tags.filter { it[0] == "z" }.map { it[1] },
)
}
// Two versions of the same personal header must resolve to the newest, not whichever came first.
@Test
fun governingHeaderIsTheNewestVersionOfTheWinningAuthor() {
val old = assertIs<AddressableListHeaderEvent>(EventFactory.create("1".repeat(64), alice, 1L, 39998, arrayOf(arrayOf("d", "dogs")), "", ""))
val new = assertIs<AddressableListHeaderEvent>(EventFactory.create("2".repeat(64), alice, 5L, 39998, arrayOf(arrayOf("d", "dogs")), "", ""))
assertEquals(new, HeaderResolution.governing(alice, "dogs", null, listOf(old, new)))
}
// Inherit-From: element 2 is an a-tag; the value form is closed.
@Test
fun bTagsWhoseTargetIsNotACoordinateAreIgnored() {
val node = item(arrayOf(arrayOf("d", "x"), arrayOf("b", "hello", "inherit"), arrayOf("b", "39998:$bob:dogs", "inherit")))
assertEquals(listOf("39998:$bob:dogs"), node.inheritFromTargets(InheritType.INHERIT))
}
@Test
fun itemKindIsTagAgreesWithParse() {
val tag = arrayOf("item-kind", "-1")
assertEquals(ItemKindTag.parse(tag) != null, ItemKindTag.isTag(tag))
assertEquals(emptyList(), arrayOf(tag).itemKinds())
}
// A long list name containing a colon is still a name, and never reaches the address parser.
@Test
fun longNamesWithColonsStayNames() {
val name = "a list of: " + "x".repeat(70)
val node = item(arrayOf(arrayOf("d", "x"), arrayOf("z", name)))
assertIs<ParentList.Name>(node.parentLists().single())
assertFalse(name in node.linkedAddressIds())
}
// Authored JSON: an explicit null in a list field must not make the whole section unreadable.
@Test
fun explicitNullListsInTheWordSectionAreTolerated() {
val wrapper = WordWrapper.parse("""{"word":{"slug":"dogs","wordTypes":null,"coreMemberOf":null}}""")!!
assertEquals("dogs", wrapper.word()?.slug)
}
}