Files
amethyst/contextvm
Claude 91747296ac feat(cordn): coordinator client, sync rules and the fixture coordinator
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
2026-09-18 17:21:28 +00:00
..

: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 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 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

  1. 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 CvmTransport exposes request() and no public publish/subscribe pair: the ordering is the transport's job, not the caller's. InMemoryRelayPool drops an event nobody is listening for, so the property is tested rather than assumed.

  2. CEP-41 has two independent ordering fields. progress orders 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 a pong's progress bears no relation to the ping it answers — they match by nonce alone.

  3. close does 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 from close. ToolCallResult carries streamed fragments and the result separately 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-rs exists.
  • Full MCP. Lifecycle, tool listing and tool calling are implemented because that is what the CEPs define; sampling, roots and elicitation are not.