refactor(contextvm): one package per CEP

`quartz` puts a NIP's implementation under `nipXX`, `marmot` puts a MIP's
under `mipXX`, and both keep named packages only for what no spec number
covers. ContextVM had neither: CEP-4 sat in `crypto/`, CEP-6/17/23/24/35 were
four unrelated specs sharing one `ServerDiscovery.kt`, CEP-8 in `payment/`.
"Which file implements CEP-17" was a grep rather than a path. Now it is
`cep17RelayList/ServerRelay`.

Four placements are judgment calls, recorded in the README so they can be
argued with:

- CEP-19 stays inside `cep04Encryption/CvmGiftWrap`. Picking the wrap kind
  (21059, falling back to 1059) and building the wrap are one negotiation in
  one class; its own package would separate a method from its only caller.
- CEP-16 gets no package. It is a `_meta` key name plus the server-side
  obligation to inject it -- a constant and fixture behaviour, not a subsystem.
- `transfer/ProgressEnvelope` stays cross-cutting: CEP-22 and CEP-41 share that
  framing, so it is the `marmot/foundation/` analogue.
- `core/`, `jsonrpc/`, `transport/` and `mcp/` keep names because the core
  draft spec is not a CEP and has no number to carry.

Pure moves and package/import rewrites; no behaviour changed. Also drops a
redundant cast the compiler flagged in `CvmMcpClient`. Suite unchanged at 172
on jvm and 143 on the Android target.

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-18 14:54:00 +00:00
parent 2fe2a95c8a
commit 4b49198190
31 changed files with 449 additions and 326 deletions
+32 -14
View File
@@ -22,20 +22,38 @@ named failures rather than as silent divergence.
| Spec | Status | Implemented in |
| ---- | ------ | -------------- |
| Core draft spec | Draft | `core/`, `jsonrpc/`, `transport/` |
| CEP-4 Encryption | Final | `crypto/CvmGiftWrap` |
| CEP-6 Public Announcements | Final | `discovery/ServerAnnouncement` |
| CEP-8 Pricing and Payment | Draft | `payment/` |
| CEP-15 Common Tool Schemas | Draft | `schema/CommonToolSchema` |
| CEP-16 Client Pubkey Injection | Final | `fixture/` (server role) |
| CEP-17 Relay List Metadata | Draft | `discovery/ServerRelay` |
| CEP-19 Ephemeral Gift Wraps | Draft | `crypto/CvmGiftWrap` |
| CEP-21 PMI Recommendations | Draft | `payment/Pmi` |
| CEP-22 Oversized Transfer | Draft | `transfer/oversized/` |
| CEP-23 Server Profile Metadata | Draft | `discovery/` (kind 0 via quartz) |
| CEP-24 Server Reviews | Draft | `discovery/ServerReview` |
| CEP-35 Stateless Discovery | Draft | `discovery/SessionDiscovery` |
| CEP-41 Open Streams | Draft | `transfer/stream/` |
| Core draft spec | Draft | `core/`, `jsonrpc/`, `transport/`, `mcp/` |
| CEP-4 Encryption | Final | `cep04Encryption/CvmGiftWrap` |
| CEP-6 Public Announcements | Final | `cep06Announcements/` |
| CEP-8 Pricing and Payment | Draft | `cep08Payments/` |
| CEP-15 Common Tool Schemas | Draft | `cep15CommonSchemas/CommonToolSchema` |
| CEP-16 Client Pubkey Injection | Final | `mcp/McpMethods` (the `_meta` key), `fixture/` (server role) |
| CEP-17 Relay List Metadata | Draft | `cep17RelayList/ServerRelay` |
| CEP-19 Ephemeral Gift Wraps | Draft | `cep04Encryption/CvmGiftWrap` |
| CEP-21 PMI Recommendations | Draft | `cep08Payments/PaymentSession` |
| CEP-22 Oversized Transfer | Draft | `cep22OversizedTransfer/` |
| CEP-23 Server Profile Metadata | Draft | `cep06Announcements/DiscoverySurface` (kind 0 via quartz) |
| CEP-24 Server Reviews | Draft | `cep24Reviews/ServerReview` |
| CEP-35 Stateless Discovery | Draft | `cep35Discovery/SessionDiscovery` |
| CEP-41 Open Streams | Draft | `cep41OpenStreams/` |
### Why the packages are named this way
Each CEP gets its own `cepNNName/` package, the way `quartz` uses `nipXX` and
`marmot` uses `mipXX`: a spec number in the path is what makes "is this rule
implemented, and where" answerable without grep. Four placements are judgment
calls rather than mechanics:
- **CEP-19 lives in `cep04Encryption/`.** Choosing the wrap kind (21059 with the
1059 fallback) and building the wrap are one negotiation inside
`CvmGiftWrap`; a separate package would split a single method from its caller.
- **CEP-16 has no package.** It is a `_meta` key name plus the server-side
obligation to inject it — a key constant and fixture behaviour, not a
subsystem.
- **`transfer/ProgressEnvelope`** stays cross-cutting because CEP-22 and CEP-41
share that framing; it is the `marmot/foundation/` analogue.
- **`core/`, `jsonrpc/`, `transport/`, `mcp/`** keep names because the core draft
spec is not a CEP and has no number to carry.
RFC 8785 (JCS), required by CEP-8 and CEP-15, lives in
`quartz/…/utils/jcs/JsonCanonicalization.kt` — it is a generic primitive, not a
@@ -18,7 +18,7 @@
* 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.contextvm.crypto
package com.vitorpamplona.contextvm.cep04Encryption
import com.vitorpamplona.contextvm.core.CvmKinds
import com.vitorpamplona.contextvm.core.CvmTags
@@ -0,0 +1,86 @@
/*
* 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.contextvm.cep06Announcements
import com.vitorpamplona.contextvm.core.CvmTags
import com.vitorpamplona.quartz.nip01Core.core.Tag
/**
* What a peer told us about itself.
*
* CEP-35 makes this a *session* concept rather than an announcement one: the
* same tag vocabulary arrives either on a public announcement (CEP-6) or on the
* first direct message of a session, and both are treated as equivalent.
*/
data class DiscoverySurface(
val name: String? = null,
val about: String? = null,
val picture: String? = null,
val website: String? = null,
val supportsEncryption: Boolean = false,
val supportsEphemeralEncryption: Boolean = false,
val supportsOversizedTransfer: Boolean = false,
val supportsOpenStream: Boolean = false,
/**
* Everything else, routing excluded.
*
* CEP-35 requires unknown discovery tags to be preserved rather than
* discarded, so custom protocols can build on the same exchange. Keeping
* them reachable is what makes that work.
*/
val unknownTags: List<Tag> = emptyList(),
) {
/** Raw access for a caller that understands a tag this version does not. */
fun rawTag(name: String): Tag? = unknownTags.firstOrNull { it.isNotEmpty() && it[0] == name }
companion object {
private val KNOWN =
setOf(
CvmTags.NAME,
CvmTags.ABOUT,
CvmTags.PICTURE,
CvmTags.WEBSITE,
CvmTags.SUPPORT_ENCRYPTION,
CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL,
CvmTags.SUPPORT_OVERSIZED_TRANSFER,
CvmTags.SUPPORT_OPEN_STREAM,
)
fun parse(tags: Array<Tag>): DiscoverySurface {
fun value(name: String) = tags.firstOrNull { it.size >= 2 && it[0] == name }?.get(1)
return DiscoverySurface(
name = value(CvmTags.NAME),
about = value(CvmTags.ABOUT),
picture = value(CvmTags.PICTURE),
website = value(CvmTags.WEBSITE),
supportsEncryption = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION),
supportsEphemeralEncryption = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL),
supportsOversizedTransfer = CvmTags.hasFlag(tags, CvmTags.SUPPORT_OVERSIZED_TRANSFER),
supportsOpenStream = CvmTags.hasFlag(tags, CvmTags.SUPPORT_OPEN_STREAM),
unknownTags =
tags.filter {
it.isNotEmpty() && it[0] !in KNOWN && !CvmTags.isRouting(it[0])
},
)
}
}
}
@@ -0,0 +1,66 @@
/*
* 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.contextvm.cep06Announcements
import com.vitorpamplona.contextvm.core.CvmKinds
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
/**
* A CEP-6 announcement.
*
* `content` is the stringified result of the matching MCP call — the initialize
* result for a server announcement, a list result for the others — so it is left
* as text for the caller to decode with the same codec it uses on the wire.
*/
data class ServerAnnouncement(
val kind: Kind,
val pubKey: HexKey,
val createdAt: Long,
val content: String,
val discovery: DiscoverySurface,
) {
companion object {
fun parseOrNull(event: Event): ServerAnnouncement? {
if (event.kind !in CvmKinds.ANNOUNCEMENTS) return null
return ServerAnnouncement(
kind = event.kind,
pubKey = event.pubKey,
createdAt = event.createdAt,
content = event.content,
discovery = DiscoverySurface.parse(event.tags),
)
}
/**
* Keeps the newest announcement per `(kind, pubkey)`.
*
* These kinds are replaceable, so an older event arriving late from a
* lagging relay must not overwrite a newer one already held.
*/
fun latestPerKind(events: List<Event>): Map<Kind, ServerAnnouncement> =
events
.mapNotNull { parseOrNull(it) }
.groupBy { it.kind }
.mapValues { (_, list) -> list.maxBy { it.createdAt } }
}
}
@@ -18,7 +18,7 @@
* 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.contextvm.payment
package com.vitorpamplona.contextvm.cep08Payments
import com.vitorpamplona.contextvm.json.toPlainJson
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcRequest
@@ -18,7 +18,7 @@
* 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.contextvm.payment
package com.vitorpamplona.contextvm.cep08Payments
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcError
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcNotification
@@ -18,7 +18,7 @@
* 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.contextvm.payment
package com.vitorpamplona.contextvm.cep08Payments
import com.vitorpamplona.quartz.nip01Core.core.Tag
@@ -18,7 +18,7 @@
* 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.contextvm.payment
package com.vitorpamplona.contextvm.cep08Payments
import com.vitorpamplona.contextvm.core.CvmTags
import com.vitorpamplona.quartz.nip01Core.core.Tag
@@ -18,7 +18,7 @@
* 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.contextvm.schema
package com.vitorpamplona.contextvm.cep15CommonSchemas
import com.vitorpamplona.contextvm.core.CvmTags
import com.vitorpamplona.contextvm.json.toPlainJson
@@ -0,0 +1,66 @@
/*
* 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.contextvm.cep17RelayList
import com.vitorpamplona.contextvm.core.CvmKinds
import com.vitorpamplona.contextvm.core.CvmTags
import com.vitorpamplona.quartz.nip01Core.core.Event
/**
* A relay a server is reachable on (CEP-17, NIP-65 kind 10002).
*
* The ContextVM profile publishes unmarked tags, meaning the relay serves both
* directions. Markers are honoured when present but are not the norm here.
*/
data class ServerRelay(
val url: String,
val read: Boolean = true,
val write: Boolean = true,
) {
companion object {
const val READ = "read"
const val WRITE = "write"
fun parseAll(event: Event): List<ServerRelay> {
if (event.kind != CvmKinds.RELAY_LIST) return emptyList()
return event.tags
.filter { it.size >= 2 && it[0] == CvmTags.RELAY && it[1].isNotBlank() }
.map { tag ->
when (tag.getOrNull(2)) {
READ -> ServerRelay(tag[1], read = true, write = false)
WRITE -> ServerRelay(tag[1], read = false, write = true)
// Unmarked is the recommended ContextVM profile: the
// relay is usable for both publishing and subscribing.
else -> ServerRelay(tag[1])
}
}
}
/**
* Relays usable for a full request/response exchange.
*
* A read-only or write-only relay cannot carry both halves, and since
* kind 25910 is ephemeral there is no fetching a response later from
* somewhere else.
*/
fun operational(relays: List<ServerRelay>) = relays.filter { it.read && it.write }
}
}
@@ -18,7 +18,7 @@
* 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.contextvm.transfer.oversized
package com.vitorpamplona.contextvm.cep22OversizedTransfer
import com.vitorpamplona.contextvm.transfer.ProgressEnvelope
import com.vitorpamplona.contextvm.transfer.ProgressEnvelope.Companion.asLongOrNull
@@ -18,7 +18,7 @@
* 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.contextvm.transfer.oversized
package com.vitorpamplona.contextvm.cep22OversizedTransfer
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcCodec
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcMessage
@@ -18,7 +18,7 @@
* 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.contextvm.transfer.oversized
package com.vitorpamplona.contextvm.cep22OversizedTransfer
import com.vitorpamplona.contextvm.transfer.ProgressToken
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -0,0 +1,84 @@
/*
* 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.contextvm.cep24Reviews
import com.vitorpamplona.contextvm.core.CvmKinds
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Tag
/** CEP-24 server reviews: NIP-22 comments anchored to a kind-11316 announcement. */
object ServerReview {
/** The addressable coordinate a review targets. */
fun coordinate(serverPubKey: HexKey) = "${CvmKinds.SERVER_ANNOUNCEMENT}:$serverPubKey:"
/**
* Tags for a top-level review.
*
* NIP-22 uses uppercase tags for the root and lowercase for the immediate
* parent; for a top-level comment both are the announcement, hence the
* apparent duplication.
*/
fun topLevelTags(
serverPubKey: HexKey,
relayHint: String? = null,
announcementEventId: HexKey? = null,
): List<Tag> {
val coordinate = coordinate(serverPubKey)
val kind = CvmKinds.SERVER_ANNOUNCEMENT.toString()
return buildList {
add(tagOf("A", coordinate, relayHint))
add(arrayOf("K", kind))
add(tagOf("P", serverPubKey, relayHint))
add(tagOf("a", coordinate, relayHint))
announcementEventId?.let { add(arrayOf("e", it, relayHint ?: "", serverPubKey)) }
add(arrayOf("k", kind))
add(tagOf("p", serverPubKey, relayHint))
}
}
/**
* Tags for a reply to an existing review.
*
* The uppercase root stays on the announcement while the lowercase parent
* moves to the comment being answered — that asymmetry is the whole point of
* NIP-22's dual tagging and is easy to get wrong.
*/
fun replyTags(
serverPubKey: HexKey,
parentCommentId: HexKey,
parentAuthor: HexKey,
relayHint: String? = null,
): List<Tag> =
buildList {
add(tagOf("A", coordinate(serverPubKey), relayHint))
add(arrayOf("K", CvmKinds.SERVER_ANNOUNCEMENT.toString()))
add(tagOf("P", serverPubKey, relayHint))
add(arrayOf("e", parentCommentId, relayHint ?: "", parentAuthor))
add(arrayOf("k", CvmKinds.REVIEW.toString()))
add(tagOf("p", parentAuthor, relayHint))
}
private fun tagOf(
name: String,
value: String,
relayHint: String?,
): Tag = if (relayHint != null) arrayOf(name, value, relayHint) else arrayOf(name, value)
}
@@ -0,0 +1,60 @@
/*
* 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.contextvm.cep35Discovery
import com.vitorpamplona.contextvm.cep06Announcements.DiscoverySurface
import com.vitorpamplona.quartz.nip01Core.core.Tag
/**
* Per-session capability learning (CEP-35).
*
* Discovery is a first-message exchange in each direction, not an
* initialize-only step: a server-to-client message that is not an initialize
* response may still carry the server's baseline. After that exchange, later
* feature tags are message-local and do not mutate the baseline unless a
* feature-specific CEP says they do — CEP-8's `payment_interaction` upsert being
* the one that does.
*/
class SessionDiscovery {
private var baseline: DiscoverySurface? = null
/** The peer's learned baseline, or null before their first message. */
val peer get() = baseline
val hasLearned get() = baseline != null
/**
* Applies a peer message's tags.
*
* The first one establishes the baseline; later ones are returned for
* message-local interpretation but leave the baseline alone.
*/
fun observe(tags: Array<Tag>): DiscoverySurface {
val surface = DiscoverySurface.parse(tags)
if (baseline == null) baseline = surface
return surface
}
/** Replaces the baseline outright. For a feature CEP that defines an update. */
fun replaceBaseline(tags: Array<Tag>) {
baseline = DiscoverySurface.parse(tags)
}
}
@@ -18,7 +18,7 @@
* 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.contextvm.transfer.stream
package com.vitorpamplona.contextvm.cep41OpenStreams
import com.vitorpamplona.contextvm.transfer.ProgressEnvelope
import com.vitorpamplona.contextvm.transfer.ProgressEnvelope.Companion.asLongOrNull
@@ -18,7 +18,7 @@
* 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.contextvm.transfer.stream
package com.vitorpamplona.contextvm.cep41OpenStreams
import com.vitorpamplona.contextvm.transfer.ProgressToken
@@ -1,266 +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.contextvm.discovery
import com.vitorpamplona.contextvm.core.CvmKinds
import com.vitorpamplona.contextvm.core.CvmTags
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.Kind
import com.vitorpamplona.quartz.nip01Core.core.Tag
/**
* What a peer told us about itself.
*
* CEP-35 makes this a *session* concept rather than an announcement one: the
* same tag vocabulary arrives either on a public announcement (CEP-6) or on the
* first direct message of a session, and both are treated as equivalent.
*/
data class DiscoverySurface(
val name: String? = null,
val about: String? = null,
val picture: String? = null,
val website: String? = null,
val supportsEncryption: Boolean = false,
val supportsEphemeralEncryption: Boolean = false,
val supportsOversizedTransfer: Boolean = false,
val supportsOpenStream: Boolean = false,
/**
* Everything else, routing excluded.
*
* CEP-35 requires unknown discovery tags to be preserved rather than
* discarded, so custom protocols can build on the same exchange. Keeping
* them reachable is what makes that work.
*/
val unknownTags: List<Tag> = emptyList(),
) {
/** Raw access for a caller that understands a tag this version does not. */
fun rawTag(name: String): Tag? = unknownTags.firstOrNull { it.isNotEmpty() && it[0] == name }
companion object {
private val KNOWN =
setOf(
CvmTags.NAME,
CvmTags.ABOUT,
CvmTags.PICTURE,
CvmTags.WEBSITE,
CvmTags.SUPPORT_ENCRYPTION,
CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL,
CvmTags.SUPPORT_OVERSIZED_TRANSFER,
CvmTags.SUPPORT_OPEN_STREAM,
)
fun parse(tags: Array<Tag>): DiscoverySurface {
fun value(name: String) = tags.firstOrNull { it.size >= 2 && it[0] == name }?.get(1)
return DiscoverySurface(
name = value(CvmTags.NAME),
about = value(CvmTags.ABOUT),
picture = value(CvmTags.PICTURE),
website = value(CvmTags.WEBSITE),
supportsEncryption = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION),
supportsEphemeralEncryption = CvmTags.hasFlag(tags, CvmTags.SUPPORT_ENCRYPTION_EPHEMERAL),
supportsOversizedTransfer = CvmTags.hasFlag(tags, CvmTags.SUPPORT_OVERSIZED_TRANSFER),
supportsOpenStream = CvmTags.hasFlag(tags, CvmTags.SUPPORT_OPEN_STREAM),
unknownTags =
tags.filter {
it.isNotEmpty() && it[0] !in KNOWN && !CvmTags.isRouting(it[0])
},
)
}
}
}
/**
* Per-session capability learning (CEP-35).
*
* Discovery is a first-message exchange in each direction, not an
* initialize-only step: a server-to-client message that is not an initialize
* response may still carry the server's baseline. After that exchange, later
* feature tags are message-local and do not mutate the baseline unless a
* feature-specific CEP says they do — CEP-8's `payment_interaction` upsert being
* the one that does.
*/
class SessionDiscovery {
private var baseline: DiscoverySurface? = null
/** The peer's learned baseline, or null before their first message. */
val peer get() = baseline
val hasLearned get() = baseline != null
/**
* Applies a peer message's tags.
*
* The first one establishes the baseline; later ones are returned for
* message-local interpretation but leave the baseline alone.
*/
fun observe(tags: Array<Tag>): DiscoverySurface {
val surface = DiscoverySurface.parse(tags)
if (baseline == null) baseline = surface
return surface
}
/** Replaces the baseline outright. For a feature CEP that defines an update. */
fun replaceBaseline(tags: Array<Tag>) {
baseline = DiscoverySurface.parse(tags)
}
}
/**
* A CEP-6 announcement.
*
* `content` is the stringified result of the matching MCP call — the initialize
* result for a server announcement, a list result for the others — so it is left
* as text for the caller to decode with the same codec it uses on the wire.
*/
data class ServerAnnouncement(
val kind: Kind,
val pubKey: HexKey,
val createdAt: Long,
val content: String,
val discovery: DiscoverySurface,
) {
companion object {
fun parseOrNull(event: Event): ServerAnnouncement? {
if (event.kind !in CvmKinds.ANNOUNCEMENTS) return null
return ServerAnnouncement(
kind = event.kind,
pubKey = event.pubKey,
createdAt = event.createdAt,
content = event.content,
discovery = DiscoverySurface.parse(event.tags),
)
}
/**
* Keeps the newest announcement per `(kind, pubkey)`.
*
* These kinds are replaceable, so an older event arriving late from a
* lagging relay must not overwrite a newer one already held.
*/
fun latestPerKind(events: List<Event>): Map<Kind, ServerAnnouncement> =
events
.mapNotNull { parseOrNull(it) }
.groupBy { it.kind }
.mapValues { (_, list) -> list.maxBy { it.createdAt } }
}
}
/**
* A relay a server is reachable on (CEP-17, NIP-65 kind 10002).
*
* The ContextVM profile publishes unmarked tags, meaning the relay serves both
* directions. Markers are honoured when present but are not the norm here.
*/
data class ServerRelay(
val url: String,
val read: Boolean = true,
val write: Boolean = true,
) {
companion object {
const val READ = "read"
const val WRITE = "write"
fun parseAll(event: Event): List<ServerRelay> {
if (event.kind != CvmKinds.RELAY_LIST) return emptyList()
return event.tags
.filter { it.size >= 2 && it[0] == CvmTags.RELAY && it[1].isNotBlank() }
.map { tag ->
when (tag.getOrNull(2)) {
READ -> ServerRelay(tag[1], read = true, write = false)
WRITE -> ServerRelay(tag[1], read = false, write = true)
// Unmarked is the recommended ContextVM profile: the
// relay is usable for both publishing and subscribing.
else -> ServerRelay(tag[1])
}
}
}
/**
* Relays usable for a full request/response exchange.
*
* A read-only or write-only relay cannot carry both halves, and since
* kind 25910 is ephemeral there is no fetching a response later from
* somewhere else.
*/
fun operational(relays: List<ServerRelay>) = relays.filter { it.read && it.write }
}
}
/** CEP-24 server reviews: NIP-22 comments anchored to a kind-11316 announcement. */
object ServerReview {
/** The addressable coordinate a review targets. */
fun coordinate(serverPubKey: HexKey) = "${CvmKinds.SERVER_ANNOUNCEMENT}:$serverPubKey:"
/**
* Tags for a top-level review.
*
* NIP-22 uses uppercase tags for the root and lowercase for the immediate
* parent; for a top-level comment both are the announcement, hence the
* apparent duplication.
*/
fun topLevelTags(
serverPubKey: HexKey,
relayHint: String? = null,
announcementEventId: HexKey? = null,
): List<Tag> {
val coordinate = coordinate(serverPubKey)
val kind = CvmKinds.SERVER_ANNOUNCEMENT.toString()
return buildList {
add(tagOf("A", coordinate, relayHint))
add(arrayOf("K", kind))
add(tagOf("P", serverPubKey, relayHint))
add(tagOf("a", coordinate, relayHint))
announcementEventId?.let { add(arrayOf("e", it, relayHint ?: "", serverPubKey)) }
add(arrayOf("k", kind))
add(tagOf("p", serverPubKey, relayHint))
}
}
/**
* Tags for a reply to an existing review.
*
* The uppercase root stays on the announcement while the lowercase parent
* moves to the comment being answered — that asymmetry is the whole point of
* NIP-22's dual tagging and is easy to get wrong.
*/
fun replyTags(
serverPubKey: HexKey,
parentCommentId: HexKey,
parentAuthor: HexKey,
relayHint: String? = null,
): List<Tag> =
buildList {
add(tagOf("A", coordinate(serverPubKey), relayHint))
add(arrayOf("K", CvmKinds.SERVER_ANNOUNCEMENT.toString()))
add(tagOf("P", serverPubKey, relayHint))
add(arrayOf("e", parentCommentId, relayHint ?: "", parentAuthor))
add(arrayOf("k", CvmKinds.REVIEW.toString()))
add(tagOf("p", parentAuthor, relayHint))
}
private fun tagOf(
name: String,
value: String,
relayHint: String?,
): Tag = if (relayHint != null) arrayOf(name, value, relayHint) else arrayOf(name, value)
}
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.contextvm.fixture
import com.vitorpamplona.contextvm.cep04Encryption.CvmGiftWrap
import com.vitorpamplona.contextvm.core.CvmKinds
import com.vitorpamplona.contextvm.core.CvmMessageEvent
import com.vitorpamplona.contextvm.crypto.CvmGiftWrap
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcMessage
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcNotification
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcRequest
@@ -20,6 +20,14 @@
*/
package com.vitorpamplona.contextvm.mcp
import com.vitorpamplona.contextvm.cep22OversizedTransfer.OversizedFrame
import com.vitorpamplona.contextvm.cep22OversizedTransfer.OversizedLimits
import com.vitorpamplona.contextvm.cep22OversizedTransfer.OversizedProgressResult
import com.vitorpamplona.contextvm.cep22OversizedTransfer.OversizedTransferReceiver
import com.vitorpamplona.contextvm.cep41OpenStreams.OpenStreamEvent
import com.vitorpamplona.contextvm.cep41OpenStreams.OpenStreamFrame
import com.vitorpamplona.contextvm.cep41OpenStreams.OpenStreamPolicy
import com.vitorpamplona.contextvm.cep41OpenStreams.OpenStreamReceiver
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcFailure
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcId
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcMessage
@@ -28,14 +36,6 @@ import com.vitorpamplona.contextvm.jsonrpc.JsonRpcRequest
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcSuccess
import com.vitorpamplona.contextvm.transfer.ProgressEnvelope
import com.vitorpamplona.contextvm.transfer.ProgressToken
import com.vitorpamplona.contextvm.transfer.oversized.OversizedFrame
import com.vitorpamplona.contextvm.transfer.oversized.OversizedLimits
import com.vitorpamplona.contextvm.transfer.oversized.OversizedProgressResult
import com.vitorpamplona.contextvm.transfer.oversized.OversizedTransferReceiver
import com.vitorpamplona.contextvm.transfer.stream.OpenStreamEvent
import com.vitorpamplona.contextvm.transfer.stream.OpenStreamFrame
import com.vitorpamplona.contextvm.transfer.stream.OpenStreamPolicy
import com.vitorpamplona.contextvm.transfer.stream.OpenStreamReceiver
import com.vitorpamplona.contextvm.transport.CvmTransport
import com.vitorpamplona.contextvm.transport.DualSigner
import com.vitorpamplona.quartz.nip01Core.core.Tag
@@ -137,7 +137,7 @@ class CvmMcpClient(
onStreamFragment: (String) -> Unit = {},
): ToolCallResult {
val id = nextId()
val token = ProgressToken.Text("call-${(id as JsonRpcId.Num).value}")
val token = ProgressToken.Text("call-${id.value}")
var oversized: OversizedTransferReceiver? = null
var stream: OpenStreamReceiver? = null
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.contextvm.transport
import com.vitorpamplona.contextvm.cep04Encryption.CvmGiftWrap
import com.vitorpamplona.contextvm.cep35Discovery.SessionDiscovery
import com.vitorpamplona.contextvm.core.CvmKinds
import com.vitorpamplona.contextvm.core.CvmMessageEvent
import com.vitorpamplona.contextvm.crypto.CvmGiftWrap
import com.vitorpamplona.contextvm.discovery.SessionDiscovery
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcFailure
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcId
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcMessage
@@ -18,7 +18,7 @@
* 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.contextvm.crypto
package com.vitorpamplona.contextvm.cep04Encryption
import com.vitorpamplona.contextvm.core.CvmKinds
import com.vitorpamplona.contextvm.core.CvmTags
@@ -18,7 +18,7 @@
* 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.contextvm.payment
package com.vitorpamplona.contextvm.cep08Payments
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcCodec
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcError
@@ -18,7 +18,7 @@
* 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.contextvm.schema
package com.vitorpamplona.contextvm.cep15CommonSchemas
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcCodec
import kotlinx.serialization.json.JsonObject
@@ -18,7 +18,7 @@
* 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.contextvm.transfer.oversized
package com.vitorpamplona.contextvm.cep22OversizedTransfer
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcCodec
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcId
@@ -18,8 +18,12 @@
* 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.contextvm.discovery
package com.vitorpamplona.contextvm.cep35Discovery
import com.vitorpamplona.contextvm.cep06Announcements.DiscoverySurface
import com.vitorpamplona.contextvm.cep06Announcements.ServerAnnouncement
import com.vitorpamplona.contextvm.cep17RelayList.ServerRelay
import com.vitorpamplona.contextvm.cep24Reviews.ServerReview
import com.vitorpamplona.contextvm.core.CvmKinds
import com.vitorpamplona.contextvm.core.CvmTags
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -33,7 +37,6 @@ import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
/** `CVM-6-*`, `CVM-17-*`, `CVM-24-*` and `CVM-35-*`. */
class DiscoveryTest {
private val serverPubKey = "a".repeat(64)
@@ -18,7 +18,7 @@
* 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.contextvm.transfer.stream
package com.vitorpamplona.contextvm.cep41OpenStreams
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcCodec
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcNotification
@@ -18,7 +18,7 @@
* 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.contextvm.crypto
package com.vitorpamplona.contextvm.cep04Encryption
import com.vitorpamplona.contextvm.core.CvmKinds
import com.vitorpamplona.contextvm.core.CvmMessageEvent
@@ -20,8 +20,10 @@
*/
package com.vitorpamplona.contextvm.mcp
import com.vitorpamplona.contextvm.crypto.CvmGiftWrap
import com.vitorpamplona.contextvm.crypto.EncryptionMode
import com.vitorpamplona.contextvm.cep04Encryption.CvmGiftWrap
import com.vitorpamplona.contextvm.cep04Encryption.EncryptionMode
import com.vitorpamplona.contextvm.cep22OversizedTransfer.OversizedTransferSender
import com.vitorpamplona.contextvm.cep41OpenStreams.OpenStreamFrame
import com.vitorpamplona.contextvm.fixture.CvmFixtureServer
import com.vitorpamplona.contextvm.fixture.InMemoryRelayPool
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcCodec
@@ -30,8 +32,6 @@ import com.vitorpamplona.contextvm.jsonrpc.JsonRpcFailure
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcId
import com.vitorpamplona.contextvm.jsonrpc.JsonRpcSuccess
import com.vitorpamplona.contextvm.transfer.ProgressToken
import com.vitorpamplona.contextvm.transfer.oversized.OversizedTransferSender
import com.vitorpamplona.contextvm.transfer.stream.OpenStreamFrame
import com.vitorpamplona.contextvm.transport.CvmTransport
import com.vitorpamplona.contextvm.transport.DualSigner
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.contextvm.transport
import com.vitorpamplona.contextvm.cep04Encryption.CvmGiftWrap
import com.vitorpamplona.contextvm.cep04Encryption.EncryptionMode
import com.vitorpamplona.contextvm.core.CvmKinds
import com.vitorpamplona.contextvm.core.CvmTags
import com.vitorpamplona.contextvm.crypto.CvmGiftWrap
import com.vitorpamplona.contextvm.crypto.EncryptionMode
import com.vitorpamplona.contextvm.fixture.CvmFixtureServer
import com.vitorpamplona.contextvm.fixture.FixtureFaults
import com.vitorpamplona.contextvm.fixture.InMemoryRelayPool
+15 -9
View File
@@ -671,21 +671,27 @@ green on jvm. What is in:
| ---------- | ----- |
| 1 constants, tags, JSON-RPC codec | `core/CvmKinds`, `core/CvmTags`, `jsonrpc/` |
| 2 minimal MCP client | `mcp/CvmMcpClient`, `mcp/McpMethods` |
| 3 CEP-4/19 gift wrap | `crypto/CvmGiftWrap` (pins `REQUIRED`) |
| 3 CEP-4/19 gift wrap | `cep04Encryption/CvmGiftWrap` (pins `REQUIRED`) |
| 4 correlation + subscription lifecycle | `transport/CvmTransport` |
| 5 CEP-35 discovery learning | `discovery/SessionDiscovery` |
| 6 CEP-6/17/23 discovery | `discovery/ServerDiscovery` |
| 5 CEP-35 discovery learning | `cep35Discovery/SessionDiscovery` |
| 6 CEP-6/17/23 discovery | `cep06Announcements/`, `cep17RelayList/ServerRelay` |
| 7 **fixture server (Tier C)** | `fixture/CvmFixtureServer`, `fixture/InMemoryRelayPool` |
| 8 CEP-22 receiver | `transfer/oversized/OversizedTransferReceiver` |
| 9 CEP-41 receiver | `transfer/stream/OpenStreamReceiver` |
| 10 CEP-22 sender | `transfer/oversized/OversizedTransferSender` |
| 8 CEP-22 receiver | `cep22OversizedTransfer/OversizedTransferReceiver` |
| 9 CEP-41 receiver | `cep41OpenStreams/OpenStreamReceiver` |
| 10 CEP-22 sender | `cep22OversizedTransfer/OversizedTransferSender` |
| 11 RFC 8785 JCS | `quartz/…/utils/jcs/JsonCanonicalization` |
| 12 CEP-15 schemas | `schema/CommonToolSchema` |
| 13 CEP-8 + CEP-21 | `payment/` |
| 12 CEP-15 schemas | `cep15CommonSchemas/CommonToolSchema` |
| 13 CEP-8 + CEP-21 | `cep08Payments/` |
| 14 CEP-16 injection | in the fixture's server role |
| 15 CEP-24 reviews | `discovery/ServerDiscovery.ServerReview` |
| 15 CEP-24 reviews | `cep24Reviews/ServerReview` |
| 16 dual-signer | `transport/DualSigner` |
Source is organized one package per CEP (`cep04Encryption/`, `cep08Payments/`, …), matching the
`nipXX` convention in `quartz` and `mipXX` in `marmot`; only what the core draft spec defines
(`core/`, `jsonrpc/`, `transport/`, `mcp/`) and the CEP-22/41 shared framing (`transfer/`) keep
names, having no CEP number to carry. `contextvm/README.md` records the four placements that are
judgment calls.
Four findings worth carrying forward, all caught by tests rather than review:
1. **CEP-22/41 ordering.** The first receiver rejected frames whose `progress` did not increase on