refactor(cordn): keep coordinator announcements in the cache, not beside it

The announced name was fetched by hand, parsed by hand, deduped by hand, kept in
a second map on CordnRuntime and pruned by hand on stop/forget/purge -- and was
still unverified, because verification is one more thing the cache does and a
hand-rolled path does not. The reason given for all of it was that
ServerAnnouncement is a parser over a raw Event "with no registered event class,
so nothing caches one". That was backwards: nothing cached one BECAUSE no class
was registered. EventCache says so itself, above the group this now joins --
"New kinds without custom consume logic go in one of the two groups below -- an
unlisted kind falls into the else branch and is rejected as unsupported."

So the kinds are typed and listed: CvmServerAnnouncementEvent (11316) and
CvmToolsListEvent (11317), both BaseReplaceableEvent, registered in EventFactory
and added to EventCache's replaceable group. From there the cache does what it
does for every other kind -- stores it, keeps the newest per (kind, pubkey)
because these kinds are replaceable, and verifies before anything trusts it
(wasVerified || justVerify) -- and the screens read the name off the cached note
through observeNoteEvent, the same event-finder data source the rest of the app
uses.

Deleted, because the cache replaces all of it: CordnRuntime.announcedNames and
its three pruning sites, announcedServerName with its hand-rolled newest-wins,
author filter and injectable verify, that function's test file, and the
announced-name plumbing through both screens' call sites. The prefetch stays, but
only to aim the request at the coordinator's own relays -- the one thing the
generic data sources cannot know until its relay list is cached.

Also drops the raw hex from Technical details. A profile shows a short npub with
a button that copies the full one and then the same for the nprofile
(DrawAdditionalInfo); nothing user-facing in this app shows 64 hex characters,
and there was no reason for a coordinator to be the exception -- the argument
that refs and device documents speak hex was about the wire encoding, not about
what a person should read. The nprofile earns its place here more than on a
profile, since its relay hints are how a coordinator is reached at all.

Still to do: CordnCoordinatorDiscovery reads surfaces off a raw sweep for
unknown servers and needs relay attribution, so it keeps its own path -- and its
own verification, which it still lacks.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
This commit is contained in:
Claude
2026-09-25 18:46:21 +00:00
parent 649d4ab459
commit 84bc76153d
10 changed files with 245 additions and 249 deletions
@@ -45,7 +45,6 @@ import com.vitorpamplona.amethyst.commons.cordn.FileCordnHandoffStore
import com.vitorpamplona.amethyst.commons.cordn.FileCordnKeyPackageStore
import com.vitorpamplona.amethyst.commons.cordn.KeyStoreCordnBlobCipher
import com.vitorpamplona.amethyst.commons.cordn.OpenedWelcome
import com.vitorpamplona.amethyst.commons.cordn.announcedServerName
import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupList
import com.vitorpamplona.quartz.contextvm.core.CvmKinds
import com.vitorpamplona.quartz.cordn.appMultiDevice.CordnHandoffCode
@@ -65,12 +64,9 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@@ -172,23 +168,6 @@ class CordnRuntime(
val coordinators = registry.coordinators
private val _announcedNames = MutableStateFlow<Map<HexKey, String>>(emptyMap())
/**
* What each coordinator calls itself, per its CEP-6 announcement.
*
* Read by the screens as the fallback between the user's own label and the
* kind 0 -- a coordinator added from discovery was picked by this name, so
* showing it keeps the two places agreeing. Filled by the same one-shot
* fetch that collects the profile and the relay list when a coordinator is
* opened, because an announcement is a raw event with no registered class
* and nothing else caches one per coordinator.
*
* Absent rather than blank when a coordinator announces no name, so the
* display can fall through instead of rendering an empty line.
*/
val announcedNames: StateFlow<Map<HexKey, String>> = _announcedNames.asStateFlow()
/**
* The session for [config], opening and starting it if it is new.
*
@@ -226,6 +205,15 @@ class CordnRuntime(
// own relays are where those live, so they are fetched here,
// from the relays this account already talks to for cordn.
//
// All three are ordinary public events about this pubkey, and now
// all three are typed, so the global cache connector files them and
// the cache does the keeping: newest-wins per (kind, pubkey) for the
// replaceable ones, and verification before anything trusts them.
// Nothing is parsed or stored by hand here any more. The fetch
// remains only to aim the request at the coordinator's OWN relays,
// which is where its announcement lives and is the one thing the
// generic data sources cannot know until its relay list is cached.
//
// Not a nicety -- it is what keeps the *outbox discovery* for
// this pubkey off this account's home relays. Rendering a
// coordinator with UserPicture/observeUserNameByHex puts it in
@@ -255,31 +243,22 @@ class CordnRuntime(
val prefetch =
scope.launch {
runCatching {
val answered =
client.fetchAll(
filters =
config.relays.associateWith {
listOf(
Filter(
kinds =
listOf(
MetadataEvent.KIND,
AdvertisedRelayListEvent.KIND,
CvmKinds.SERVER_ANNOUNCEMENT,
),
authors = listOf(config.pubKey),
),
)
},
)
// The announcement is the one of the three the cache
// cannot keep: no registered event class, so it would be
// parsed by nobody and dropped. Read here, from the
// events this fetch returned, and kept for the screens.
announcedServerName(answered, config.pubKey)?.let { name ->
_announcedNames.update { it + (config.pubKey to name) }
}
client.fetchAll(
filters =
config.relays.associateWith {
listOf(
Filter(
kinds =
listOf(
MetadataEvent.KIND,
AdvertisedRelayListEvent.KIND,
CvmKinds.SERVER_ANNOUNCEMENT,
),
authors = listOf(config.pubKey),
),
)
},
)
}
}
// A coordinator that stops answering is otherwise invisible:
@@ -602,7 +581,6 @@ class CordnRuntime(
}
registry.close()
groups.clear()
_announcedNames.value = emptyMap()
}
/** Drops one coordinator, leaving its stored groups on disk. */
@@ -615,10 +593,6 @@ class CordnRuntime(
}
}
registry.forget(coordinatorPubKey)
// A name learned about a coordinator this account no longer holds is
// nobody's to show, and keeping it would resurrect it on a re-add before
// the fresh announcement arrives.
_announcedNames.update { it - coordinatorPubKey }
remember()
}
@@ -640,7 +614,6 @@ class CordnRuntime(
suspend fun purge(coordinatorPubKey: HexKey) {
forget(coordinatorPubKey)
groups.forgetCoordinator(coordinatorPubKey)
_announcedNames.update { it - coordinatorPubKey }
withContext(Dispatchers.IO) {
CordnStorageLayout.directoryFor(filesDir, accountSigner.pubKey, coordinatorPubKey).deleteRecursively()
}
@@ -70,14 +70,17 @@ import com.vitorpamplona.amethyst.commons.cordn.ui.CordnExposureCard
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cordnGroups.CordnGroupChatroom
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.back
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.copy_npub_to_clipboard
import com.vitorpamplona.amethyst.commons.resources.cordn_group_untitled
import com.vitorpamplona.amethyst.commons.ui.components.EmptyState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.theme.SuggestionListDefaultHeightChat
import com.vitorpamplona.amethyst.commons.util.toShortDisplay
import com.vitorpamplona.amethyst.model.cordn.CordnRuntime
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
@@ -176,18 +179,11 @@ private fun CordnGroupInfo(
val runtime = accountViewModel.account.cordnRuntime
val manager = runtime?.sessionOrNull(coordinatorPubKey)?.manager
// What to call this coordinator, cheapest source first. The label and the
// announced name are both already in hand -- neither costs a call to the
// coordinator, which matters because serverInfo() is a live MCP request over
// kind 25910 and a screen that fired one on open would tell the coordinator
// every time somebody glanced at a group (spec/00.md §8).
val announcedNames =
runtime
?.announcedNames
?.collectAsStateWithLifecycle()
?.value
.orEmpty()
// The user's own name for it, when they gave it one. Read from the stored
// config, never from the coordinator: serverInfo() is a live MCP request over
// kind 25910, and a screen that fired one on open would tell the coordinator
// every time somebody glanced at a group (spec/00.md §8). The announced name
// and the profile are read from the cache by CoordinatorIdentityRow itself.
val coordinatorLabel =
runtime
?.coordinators
@@ -467,7 +463,6 @@ private fun CordnGroupInfo(
CoordinatorIdentityRow(
pubKey = coordinatorPubKey,
label = coordinatorLabel,
announced = announcedNames[coordinatorPubKey],
accountViewModel = accountViewModel,
nav = nav,
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
@@ -660,11 +655,78 @@ private fun TechnicalDetails(
}
AnimatedVisibility(visible = expanded, enter = SectionExpand, exit = SectionCollapse) {
SelectionContainer {
Column {
InfoRow(stringRes(R.string.cordn_info_coordinator_key), coordinatorPubKey)
InfoRow(stringRes(R.string.cordn_info_gid), gid)
InfoRow(stringRes(R.string.cordn_info_epoch), epoch.toString())
Column {
// Through the User, in the encodings the rest of the app uses for a
// person's key. A profile screen shows a short npub with a button that
// copies the full one, then does the same for the nprofile
// (DrawAdditionalInfo); nothing user-facing anywhere shows raw hex,
// and there was no reason for a coordinator to be the exception. The
// nprofile earns its place here more than on a profile, because it
// carries the relay hints that are how a coordinator is reached at all.
val coordinator = remember(coordinatorPubKey) { LocalCache.getOrCreateUser(coordinatorPubKey) }
CopyableKeyRow(
label = stringRes(R.string.cordn_info_coordinator_key),
shown = coordinator.pubkeyDisplayHex(),
copied = coordinator.pubkeyNpub(),
copyDescription = stringRes(Res.string.copy_npub_to_clipboard),
)
CopyableKeyRow(
label = stringRes(R.string.cordn_info_coordinator_nprofile),
shown = coordinator.toNProfile().toShortDisplay(6),
copied = coordinator.toNProfile(),
copyDescription = stringRes(R.string.cordn_info_copy_nprofile),
)
SelectionContainer {
Column {
InfoRow(stringRes(R.string.cordn_info_gid), gid)
InfoRow(stringRes(R.string.cordn_info_epoch), epoch.toString())
}
}
}
}
}
/**
* A key, short enough to read, with a button that copies the whole thing.
*
* The shape a profile uses for the same job: nobody reads a bech32 string off a
* screen, they copy it, so the visible half is there to confirm which key it is
* and the button is there to do the actual work.
*/
@Composable
private fun CopyableKeyRow(
label: String,
shown: String,
copied: String,
copyDescription: String,
) {
val clipboard = LocalClipboardManager.current
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold,
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = shown,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
)
IconButton(
onClick = { clipboard.setText(AnnotatedString(copied)) },
modifier = Modifier.size(24.dp).padding(start = 4.dp),
) {
Icon(
symbol = MaterialSymbols.ContentCopy,
contentDescription = copyDescription,
modifier = Modifier.size(15.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -24,14 +24,20 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEvent
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.feed.types.observeUserNameByHex
import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
@@ -48,10 +54,10 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
* 1. `CoordinatorConfig.label` -- documented as "what the user calls it. Never
* a claim -- a coordinator cannot prove a name." The only name here that
* means anything, so it wins.
* 2. The CEP-6 announcement's name, when one was heard. Before the profile
* because a coordinator added from discovery was *picked* by this name, and
* having the settings screen rename it afterwards would be its own small
* confusion.
* 2. The CEP-6 announcement's name, when the cache holds one. Before the
* profile because a coordinator added from discovery was *picked* by this
* name, and having the settings screen rename it afterwards would be its own
* small confusion.
* 3. The kind 0's display name (CEP-23).
* 4. The key's first characters.
*
@@ -65,19 +71,42 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
fun coordinatorDisplayName(
pubKey: HexKey,
label: String?,
announced: String?,
accountViewModel: AccountViewModel,
): String {
// Unconditional: this is what subscribes for the kind 0 (observeUserName
// registers a UserFinder subscription), and it must not come and go with
// whether a label or an announcement happens to be present.
// Both unconditional: each one registers the subscription that fetches what
// it reads, so neither may come and go with whether an earlier source in the
// chain happens to have an answer.
val fromProfile = observeUserNameByHex(pubKey, accountViewModel)
val announced = observeAnnouncedServerName(pubKey, accountViewModel)
return label?.takeIf { it.isNotBlank() }
?: announced?.takeIf { it.isNotBlank() }
?: announced
?: fromProfile
}
/**
* The name from the coordinator's CEP-6 announcement, as the cache holds it.
*
* A [CvmServerAnnouncementEvent] is a replaceable event like any other now, so
* reading it here gets the newest one per coordinator, already verified, and
* fetched by the same event-finder data source the rest of the app uses --
* rather than a hand-rolled fetch, a hand-rolled newest-wins and a second copy
* of the name kept beside the cache.
*/
@Composable
fun observeAnnouncedServerName(
pubKey: HexKey,
accountViewModel: AccountViewModel,
): String? {
val note =
remember(pubKey) {
LocalCache.getOrCreateAddressableNote(Address(CvmServerAnnouncementEvent.KIND, pubKey, ""))
}
val announcement by observeNoteEvent<CvmServerAnnouncementEvent>(note, accountViewModel)
return announcement?.serverName()
}
/**
* A coordinator rendered as the user it is: avatar, name, and whatever acts on
* it.
@@ -96,7 +125,6 @@ fun coordinatorDisplayName(
fun CoordinatorIdentityRow(
pubKey: HexKey,
label: String?,
announced: String?,
accountViewModel: AccountViewModel,
nav: INav,
modifier: Modifier = Modifier,
@@ -110,7 +138,7 @@ fun CoordinatorIdentityRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
UserPicture(userHex = pubKey, size = size, accountViewModel = accountViewModel, nav = nav)
name(coordinatorDisplayName(pubKey, label, announced, accountViewModel))
name(coordinatorDisplayName(pubKey, label, accountViewModel))
trailing()
}
}
@@ -194,7 +194,6 @@ private fun CoordinatorCard(
var renaming by remember(config.pubKey) { mutableStateOf(false) }
val health = runtime.health(config.pubKey)?.collectAsStateWithLifecycle()?.value
val announced by runtime.announcedNames.collectAsStateWithLifecycle()
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
@@ -205,7 +204,6 @@ private fun CoordinatorCard(
CoordinatorIdentityRow(
pubKey = config.pubKey,
label = config.label,
announced = announced[config.pubKey],
accountViewModel = accountViewModel,
nav = nav,
trailing = {
+2
View File
@@ -349,6 +349,8 @@
<string name="cordn_send">Send</string>
<string name="cordn_info_coordinator">Coordinator</string>
<string name="cordn_info_coordinator_key">Coordinator key</string>
<string name="cordn_info_coordinator_nprofile">Coordinator link</string>
<string name="cordn_info_copy_nprofile">Copy the coordinator\'s nprofile</string>
<string name="cordn_info_gid">Group id</string>
<string name="cordn_info_epoch">Epoch</string>
<string name="cordn_info_members">Members</string>
@@ -35,39 +35,6 @@ import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
/**
* The name a coordinator announces for itself, or null if it announces none.
*
* The same rule [CordnCoordinatorDiscovery] applies when it builds a
* [DiscoveredCoordinator]: the CEP-6 announcement kinds are replaceable, so the
* newest per kind wins, and the name lives on the server announcement's
* discovery surface. Shared rather than re-derived, because "newest wins" is
* the part that is easy to get subtly wrong when a lagging relay answers late.
*
* Its own function because a coordinator this account already uses never goes
* through discovery again, and until this existed there was nowhere for its
* announced name to come from: the announcement is a raw [Event] with no
* registered event class, so nothing caches one per coordinator.
*
* ## [pubKey] is required, not optional
*
* A REQ's `authors` is what was asked for, never a guarantee of what came back:
* a relay may answer with anything. Discovery is safe from that because it
* groups by author before it reads a surface; a caller handing over a flat fetch
* result has done no such thing, and one relay in a coordinator's own list could
* otherwise choose the name shown for it. So the filter lives here rather than
* in each caller, where it is one line to forget.
*/
fun announcedServerName(
events: List<Event>,
pubKey: HexKey,
): String? =
ServerAnnouncement
.latestPerKind(events.filter { it.pubKey == pubKey })[CvmKinds.SERVER_ANNOUNCEMENT]
?.discovery
?.name
?.takeIf { it.isNotBlank() }
/**
* A coordinator found by listening for CEP-6 announcements.
*
@@ -139,6 +139,8 @@ import com.vitorpamplona.quartz.buzz.wpWorkspaceProfile.SetWorkspaceProfileEvent
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChannelId
import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent
import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmToolsListEvent
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
@@ -3780,6 +3782,8 @@ open class EventCache :
// ============================================================
is AcceptedBadgeSetEvent,
is AdvertisedRelayListEvent,
is CvmServerAnnouncementEvent,
is CvmToolsListEvent,
is AppDefinitionEvent,
is AppRecommendationEvent,
is AppSpecificDataEvent,
@@ -1,133 +0,0 @@
/*
* 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.amethyst.commons.cordn
import com.vitorpamplona.quartz.contextvm.core.CvmKinds
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Tag
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* The name a coordinator this account already uses calls itself.
*
* Discovery reads this from a live sweep, but a coordinator already in the list
* never goes through discovery again -- so the rule has to hold on whatever a
* one-shot fetch happens to return, including a lagging relay's stale copy.
*/
class AnnouncedServerNameTest {
private val coordinator = "c".repeat(64)
@Test
fun `the name comes off the server announcement`() {
assertEquals("Cordn Demo", announcedServerName(listOf(announcement(name = "Cordn Demo")), coordinator))
}
@Test
fun `a coordinator that announces no name has none, rather than an empty one`() {
// Absent and blank have to read the same, or the display chain stops at
// a name that renders as nothing instead of falling through to the
// profile and then the key.
assertNull(announcedServerName(listOf(announcement(name = "")), coordinator))
assertNull(announcedServerName(listOf(announcement(name = " ")), coordinator))
assertNull(announcedServerName(emptyList(), coordinator))
}
@Test
fun `the newest announcement wins, whatever order the relays answered in`() {
// These kinds are replaceable. A relay that is behind will happily hand
// back last month's name after a fresher one already arrived, and taking
// the last event seen would show the old one.
val stale = announcement(name = "Old Name", createdAt = 1_000)
val fresh = announcement(name = "New Name", createdAt = 2_000)
assertEquals("New Name", announcedServerName(listOf(fresh, stale), coordinator))
assertEquals("New Name", announcedServerName(listOf(stale, fresh), coordinator))
}
@Test
fun `an announcement by anybody else is not this coordinator's name`() {
// A REQ's `authors` is what was asked for, not a promise about what came
// back. Without the filter, one relay in a coordinator's own list could
// choose the name shown for it -- and that name ranks above its kind 0.
val impostor =
event(
kind = CvmKinds.SERVER_ANNOUNCEMENT,
createdAt = 9_000,
content = """{"protocolVersion":"2025-11-25"}""",
tags = arrayOf(arrayOf("name", "Not This Server")),
pubKey = "d".repeat(64),
)
assertNull(announcedServerName(listOf(impostor), coordinator))
// And it does not outrank the real one by being newer, either.
assertEquals(
"Real Name",
announcedServerName(listOf(impostor, announcement(name = "Real Name")), coordinator),
)
}
@Test
fun `the other announcement kinds do not supply a name`() {
// CEP-6 has five announcement kinds and only the server announcement
// carries the surface. A tools list with a name tag is not this server
// saying what it is called.
val tools =
event(
kind = CvmKinds.TOOLS_LIST,
createdAt = 5_000,
content = """{"tools":[]}""",
tags = arrayOf(arrayOf("name", "Tools List")),
)
assertNull(announcedServerName(listOf(tools), coordinator))
}
private fun announcement(
name: String,
createdAt: Long = 1_000,
) = event(
kind = CvmKinds.SERVER_ANNOUNCEMENT,
createdAt = createdAt,
content = """{"protocolVersion":"2025-11-25"}""",
tags = arrayOf(arrayOf("name", name)),
)
/** Unsigned: nothing here verifies a signature, and an announcement proves nothing either way. */
private fun event(
kind: Int,
createdAt: Long,
content: String,
tags: Array<Tag> = emptyArray(),
pubKey: HexKey = coordinator,
) = Event(
id = "${kind}_$createdAt",
pubKey = pubKey,
createdAt = createdAt,
kind = kind,
tags = tags,
content = content,
sig = "00".repeat(32),
)
}
@@ -0,0 +1,91 @@
/*
* 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.contextvm.cep06Announcements
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.contextvm.core.CvmKinds
import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent
import com.vitorpamplona.quartz.nip01Core.core.HexKey
/**
* A server's CEP-6 announcement of itself: kind [CvmKinds.SERVER_ANNOUNCEMENT].
*
* A typed class so the announcement can live in the event cache like anything
* else. Without one the kind is unlisted, and an unlisted kind is rejected as
* unsupported on the way in -- which is why a coordinator's announced name had
* to be fetched and parsed by hand everywhere it was wanted, with the
* newest-wins rule, the author check and the signature check all re-derived per
* caller. Being replaceable (10000..19999), the cache now keeps the newest per
* (kind, pubkey) and verifies before trusting it.
*
* [discovery] is the surface the server advertises -- its name, its blurb, and
* what it says it supports. Every field of it is the server's own claim;
* [pubKey] is the only thing here that is not.
*/
@Immutable
class CvmServerAnnouncementEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
/**
* What the server says about itself, parsed from the tags.
*
* Not cached on the instance: the cache keeps one event per coordinator and
* the screens read a name off it, so the parse is rare and cheap next to
* holding a second copy of every surface in memory.
*/
fun discovery(): DiscoverySurface = DiscoverySurface.parse(tags)
/** The server's own name for itself, or null when it publishes none. */
fun serverName(): String? = discovery().name?.takeIf { it.isNotBlank() }
companion object {
const val KIND = CvmKinds.SERVER_ANNOUNCEMENT
}
}
/**
* A server's CEP-6 `tools/list` announcement: kind [CvmKinds.TOOLS_LIST].
*
* Stored for the same reason as [CvmServerAnnouncementEvent], and needed
* alongside it because what makes a ContextVM server a *cordn coordinator* is
* the eleven tools it advertises here, not anything it says about itself.
*/
@Immutable
class CvmToolsListEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) {
/** The advertised tools, or null when the content does not parse as a list. */
fun tools(): AnnouncedTools? = AnnouncedTools.parseOrNull(content)
companion object {
const val KIND = CvmKinds.TOOLS_LIST
}
}
@@ -103,6 +103,8 @@ import com.vitorpamplona.quartz.concord.cord03Channels.ConcordChatEditEvent
import com.vitorpamplona.quartz.concord.cord04Roles.control.ControlEditionEvent
import com.vitorpamplona.quartz.concord.cord05Invites.ConcordInviteListEvent
import com.vitorpamplona.quartz.concord.cord05Invites.bundle.ConcordInviteBundleEvent
import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmServerAnnouncementEvent
import com.vitorpamplona.quartz.contextvm.cep06Announcements.CvmToolsListEvent
import com.vitorpamplona.quartz.cyberspace.CyberspaceBagEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoAvatarEvent
import com.vitorpamplona.quartz.cyberspace.deck0003Sno.SnoObjectEvent
@@ -461,6 +463,8 @@ class EventFactory {
AcceptedBadgeSetEvent.KIND -> AcceptedBadgeSetEvent(id, pubKey, createdAt, tags, content, sig)
ConcordChatEditEvent.KIND -> ConcordChatEditEvent(id, pubKey, createdAt, tags, content, sig)
AdvertisedRelayListEvent.KIND -> AdvertisedRelayListEvent(id, pubKey, createdAt, tags, content, sig)
CvmServerAnnouncementEvent.KIND -> CvmServerAnnouncementEvent(id, pubKey, createdAt, tags, content, sig)
CvmToolsListEvent.KIND -> CvmToolsListEvent(id, pubKey, createdAt, tags, content, sig)
AgentTurnMetricEvent.KIND -> AgentTurnMetricEvent(id, pubKey, createdAt, tags, content, sig)
EngramEvent.KIND -> EngramEvent(id, pubKey, createdAt, tags, content, sig)
AgentProfileEvent.KIND -> AgentProfileEvent(id, pubKey, createdAt, tags, content, sig)