Completes the core of Stage 3. **CoordinatorClient** types the eleven tools, and identity is not a parameter: each method's signing key comes from CoordinatorMethod, so `msg_post` under the account key is not a mistake that is available. That matters because it would work perfectly -- and tie every message to a real npub on a server that keeps ordered history forever. **The sync rules are the part where a mistake is silent**, so they are a pure state machine with the cursor advance inside it: - A self-echo is confirmation, not work. Feeding our own Commit back through MLS advances the epoch twice and nothing complains until messages stop decrypting several epochs later. Matching is on the sealed ciphertext, which is unique per posting because spec/03 §4 requires a fresh nonce -- cursor would break on renumbering, plaintext would mean decrypting our own traffic to recognise it. - Except when we posted and died before adopting the new epoch, where that echo is the only copy of the Commit we will ever be handed. - The cursor advances past everything, including undecryptable messages. One sealed under an epoch we never had is unreadable forever, so refusing to move past it stalls that group permanently. **Found a real hole in `:contextvm`'s fixture.** It answered every call with a constant JSON-RPC id, which passed all 172 contextvm tests because each made exactly one call -- and hung the first cordn test that made two. The handler now takes the request id, and two new contextvm tests pin what was untested: a second call correlates, and a stale id is ignored rather than resolving the wrong call. The production correlation logic was right the whole time; nothing had asked it the question. **CordnFixtureCoordinator** implements the eleven tools in memory and records which identity made each call. That is the only way to test the privacy claim of spec/00 §8: it is about what the coordinator LEARNS, so you have to stand on its side of the wire and look. Two tests do -- the message path never touches the stable key, and publication and admission always do, because a KeyPackage that did not name its owner would bind nothing. Verified by mutation: ignoring a pending self-echo, and advancing the cursor only for processed messages, each kill their guarding tests. The second needed a new end-to-end test first -- a single catch-up pass looks correct either way, and only a second pass reveals the stall. That is the shape of this bug in production too. 49 tests on jvm, 33 on the Android target. contextvm 172 -> 174. README records the five things that are easy to get wrong, what the coordinator learns, and the two spec/implementation divergences found while building. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012BfD4txdnsaPRXmNXbup9n
:contextvm
A Kotlin Multiplatform client for ContextVM — the Model Context Protocol (MCP) carried over Nostr.
This is a general MCP-over-Nostr implementation, not a client for any one
server. It exists because Amethyst needs to talk to
cordn coordinators (see
quartz/plans/2026-09-17-cordn-interop.md), but nothing in it is
cordn-specific.
Implemented specification revision
Built from the specification documents in
ContextVM/contextvm-docs at
commit e63bce6, read on 2026-09-17.
Most of these are Draft, including both transfer profiles, so the text can
still move. When bumping the revision, diff against the CVM-* rule ids in the
plan's §6.5 catalog — the test names carry them, so a spec change shows up as
named failures rather than as silent divergence.
| Spec | Status | Implemented in |
|---|---|---|
| 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 insideCvmGiftWrap; a separate package would split a single method from its caller. - CEP-16 has no package. It is a
_metakey name plus the server-side obligation to inject it — a key constant and fixture behaviour, not a subsystem. transfer/ProgressEnvelopestays cross-cutting because CEP-22 and CEP-41 share that framing; it is themarmot/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
ContextVM concern.
Licensing
Implemented clean-room from the specification. The reference
ContextVM/sdk is LGPL-3.0; Amethyst
ships under MIT, so translating that source would carry copyleft terms into
Quartz. Do not read it while working here — the plan's §6 was written so you do
not have to.
The spec repository carries no LICENSE file. Implementing a published protocol is fine; do not paste spec prose into this repo.
Three things that are easy to get wrong
-
Kind 25910 is ephemeral, so relays do not store it. A subscription opened after the peer published has missed the response permanently. This is why
CvmTransportexposesrequest()and no public publish/subscribe pair: the ordering is the transport's job, not the caller's.InMemoryRelayPooldrops an event nobody is listening for, so the property is tested rather than assumed. -
CEP-41 has two independent ordering fields.
progressorders every frame, control frames included, and is explicitly not a chunk counter;chunkIndex(contiguous from 0) is what validates completeness. Progress sequences are also per-sender, so apong's progress bears no relation to thepingit answers — they match by nonce alone. -
closedoes not complete the request. After a CEP-41 stream closes, the originating JSON-RPC request still needs its own response, and a client must never synthesize success fromclose.ToolCallResultcarriesstreamedfragments and theresultseparately for exactly this reason.
Testing
./gradlew :contextvm:jvmTest # 172 tests
./gradlew :contextvm:testAndroidHostTest # 143 tests
The Android run is smaller because the tests needing real secp256k1 and NIP-44
— the gift wrap round trip, the transport and the MCP client — live in
src/jvmTest where the JVM JNI artifact is on the classpath. Everything
protocol-level is in commonTest and runs on both.
The suite is Tier A and Tier C from the plan's §6.4: rule-derived unit
tests, plus adversarial tests against fixture/CvmFixtureServer, which plays
the server role and can be told to violate any rule on demand (FixtureFaults).
Most tests are negative, because the CEPs are written as failure conditions.
Still open:
- Tier B — live integration against
ghcr.io/cordn-msg/cordn:latest. - Tier D — cross-implementation vector exchange for CEP-4 wraps and CEP-22/41 framing. Worth offering upstream; no official vectors exist.
- Tier E — a real Lightning wallet behind CEP-8 (NIP-47 NWC is already in Quartz).
Not in scope
- A production server.
fixture/plays the server role for tests only and is deliberately not hardened; for a real coordinator,cordn-rsexists. - Full MCP. Lifecycle, tool listing and tool calling are implemented because that is what the CEPs define; sampling, roots and elicitation are not.