feat(cordn): verify the coordinator wire against cordn's own contracts

Everything cordn-side was verified against ts-mls, the MLS library their client
happens to use. That covers the crypto layer and says nothing about the layer
above it: the eleven coordinator tools, their argument names, which fields are
optional, and the `cordn1…` group ref. That layer rested on our reading of the
spec.

`quartz/tools/cordn-vector-gen` closes it from their side. It generates
`resources/cordn/coordinator-contracts.json` from `@cordn/core` (MIT) -- the
package their reference coordinator and client both import -- by handing each
payload we send to their zod schema, so a field we named wrong fails at
generation time. Each method also carries a `rejects` set their schema must
refuse, because a positive only means something if the schema is strict where
we assume it is. Group refs round-trip through their bech32 codec.

- CoordinatorContractVectorTest (15): drives CoordinatorClient through the real
  ContextVM transport, asserts the arguments the coordinator sees equal those
  vectors, then replays their result shapes back through our parser.
- CordnGroupRefVectorTest (5): both directions, including the uppercase form
  (their `decodeGroupRef` accepts it though their `isGroupRef` screen does not)
  and a non-ASCII gid, which §4.1 requires to survive byte for byte.
- CordnFixtureCoordinator now records each call's arguments and can serve a
  scripted result, which is what lets the same fixture do both directions.

Mutation-checked, since 20 tests passing on the first run means nothing on its
own: sending `after = 0` on a first-ever fetch instead of omitting it, and
flipping the group-ref TLV emission to ascending order, each killed exactly one
test. The TLV mutation was caught only by the encode-direction test -- decoders
accept any order by spec, so a decode-only suite would have shipped that
divergence.

Also settles §4.1 (credential encoding) by implementing both rather than
waiting on an agreement neither ecosystem has an incentive to reach. Marmot
writes a pubkey as 32 raw bytes, cordn as 64 ASCII hex bytes; they are mutually
exclusive by length, so no leaf can be misread as the other profile's.
BothCredentialProfilesTest runs both groups on one engine and pins the
`memberIdentityHex` hex-of-hex trap that would otherwise drop cordn members
from a member list.

Two findings recorded in the plan:

- Tier B is blocked on licensing, not tooling. `packages/coordinator`,
  `packages/server` and `packages/test-utils` carry no LICENSE file and no
  `license` field, and neither does the repo root -- so the reference
  coordinator, and the ghcr image built from it, are unlicensed. Only
  `packages/core` and `packages/cli` are MIT. The plan's claim that
  "everything under Cordn-msg is MIT" was wrong and is corrected.
- Their current `kp_publish` schema rejects the legacy `keyPackageBase64`
  field, so our fallback is for parsing old publication events only.

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 22:41:37 +00:00
parent 65c683c9c7
commit 5527bea451
10 changed files with 1504 additions and 25 deletions
+105 -17
View File
@@ -1,12 +1,14 @@
# Cordn interop: extract the MLS core, then add a second binding
Status: Stages 1, 2 and the core of 3 landed. The RFC 9420 engine is `quartz/…/mls/` and imports
Status: Stages 1, 2, the core of 3 and the wire half of Stage 0 landed. The RFC 9420 engine is `quartz/…/mls/` and imports
nothing from `marmot/` — a binding supplies its rules through `MlsGroupPolicy`. `quartz/…/contextvm/`
implements the core spec plus all 12 CEPs on the client side, with the Tier C fixture server;
`quartz/…/cordn/` implements the MLS profile, the eleven coordinator tools, the seal, envelopes,
group refs and the sync rules, verified against ts-mls in both directions. Stage 0 (cordn-side vectors) and Stage 4 (app integration)
are open. §4.1 turned out not to gate the binding — see Stage 3 — but remains a real
incompatibility between the two ecosystems.
group refs and the sync rules, verified against ts-mls in both directions **and against cordn's
own wire contracts** (`quartz/tools/cordn-vector-gen` → `resources/cordn/`). Stage 4 (app
integration) is open, as is Tier B — which turns out to be blocked on licensing, not on
tooling (§7). §4.1 turned out not to gate the binding — see Stage 3 — and is now settled on our
side by implementing both encodings rather than waiting for an agreement (§4.1).
Correction to an earlier gate in this plan: §4.1 does **not** block Stage 2. ContextVM is
credential-agnostic and has no MLS dependency at all, so the transport was safe to build first;
@@ -25,11 +27,14 @@ Sources checked on 2026-09-17:
- `ContextVM/sdk` @ `b5d1e4e` (2026-09-17), version `0.13.17` — **LGPL-3.0**, see §7. Read only to
confirm deployed defaults, never as an implementation source
Verification status: `:quartz:jvmTest` passes in a container at 5330 tests, covering the MLS
engine, ContextVM and cordn, so the interop claims in §3 are execution-verified rather than
read-verified. `:quartz:testAndroidHostTest` passes too, apart from four pre-existing failures in
`NostrServerTest` and `LiveNegentropyIndexStoreTest` that predate this work (confirmed by running
them at the parent commit).
Verification status: `:quartz:jvmTest` passes in a container, covering the MLS engine, ContextVM
and cordn, so the interop claims in §3 are execution-verified rather than read-verified.
`:quartz:testAndroidHostTest` passes as well — the four failures this plan previously recorded as
pre-existing (`NostrServerTest`, `LiveNegentropyIndexStoreTest`) were fixed on 2026-09-18: the
event store classified insert failures by parsing the SQLite driver's exception message, and
Android's carries none. Note the source-set shape when reading test counts: `jvmTest` dependsOn
`jvmAndroidTest`, but `androidHostTest` does **not**, so the cordn and ContextVM suites under
`jvmAndroidTest/` run on the JVM target only.
## 1. Executive summary
@@ -139,8 +144,22 @@ One KeyPackage cannot satisfy both. This is exactly what `spec/00.md` §13 requi
implementations to agree on, and the two ecosystems picked differently. It is a one-line change
on either side today and unfixable once either has deployed users at scale.
**Action:** raise with gzuuus before Stage 2. Either side moving is fine; what matters is that
one does.
**Decision (2026-09-18): support both encodings; do not wait for an agreement.** Neither
ecosystem is expected to move, and neither needs to. The credential encoding is a *binding*
choice, and since Stage 1 the engine no longer has an opinion about it — `MlsGroupPolicy` picks
the profile, `MarmotCapabilities`/`CordnCredential` supply the encoding. Marmot KeyPackages carry
the raw 32 bytes; cordn KeyPackages carry the 64 ASCII hex bytes; both are produced and read by
the same engine, and neither can be mistaken for the other (32 raw bytes is not valid 64-char
ASCII hex, so `CordnCredential.identityOrNull` and `KeyPackageUtils`'s 32-byte check are
mutually exclusive by construction).
What this costs is §4.4: a member advertising only one profile's capabilities cannot be added to
the other's groups, so "one MLS group, both clients" stays out of reach. That was already a
deliberate profile decision rather than a consequence of this one. What it buys is that Amethyst
speaks both today rather than blocking on a conversation neither side has an incentive to finish.
Still worth raising with gzuuus as a fact rather than a request — an implementation that reads
both is useful evidence for whichever encoding a future joint profile picks.
### 4.2 No key-package event kind
@@ -545,8 +564,38 @@ dependency rule in `.claude/CLAUDE.md` this means:
- If a Kotlin/JVM ContextVM library ever appears under LGPL, linking it is a WARN-and-call-out
(call it out in the PR description), not an automatic stop.
Everything under `Cordn-msg` is MIT, so the specs and the reference coordinator are safe to read
and to implement against.
🔴 **Correction (2026-09-18, verified): "everything under `Cordn-msg` is MIT" was wrong.** Only
two of the five packages are licensed at all. Checked against the actual files at `b465df0` and
against the published npm tarballs:
| Package | `LICENSE` file | `license` field | Published to npm |
| ------- | -------------- | --------------- | ---------------- |
| `packages/core` | yes | MIT | `@cordn/core` |
| `packages/cli` | yes | MIT | `@cordn/cli` |
| `packages/coordinator` | **no** | **none** | no |
| `packages/server` | **no** | **none** | no |
| `packages/test-utils` | **no** | **none** | no |
The repository root has no LICENSE either. So the **reference coordinator is unlicensed** —
default copyright, all rights reserved — and so is the `ghcr.io/cordn-msg/cordn` image built from
it. Almost certainly an oversight rather than intent, given the two licensed siblings, but the
rule in `.claude/CLAUDE.md` does not have an "obviously meant to be MIT" branch.
Consequences, and they are the reason Stage 0 landed the way it did:
- **Tier B is blocked, not merely awkward.** The plan's Tier B (run their coordinator locally with
`CORDN_STORAGE_BACKEND=memory`) rests entirely on unlicensed code. Not a shipping dependency,
but it would become a documented, committed part of our test process, which is exactly what the
dependency rule exists to stop happening quietly. Docker being unavailable in the build
container is the lesser problem.
- **`@cordn/core` is fine and is enough for the valuable half.** It is MIT, it ships a LICENSE,
and it holds the authoritative zod contracts for all eleven tools plus the group-ref bech32
codec — i.e. the wire surface. That is what `quartz/tools/cordn-vector-gen` uses.
- **Ask upstream for a LICENSE on the remaining packages.** Cheap for them, and it is what
unblocks Tier B. Worth raising alongside §4.2.
The specs themselves (`spec/`, `design/`) are also unlicensed, so the existing rule stands: we
implement from them, we do not paste their prose into KDoc.
## 8. What the coordinator can see
@@ -623,9 +672,38 @@ looking like a group chat.
## 9. Plan
### Stage 0 — Ground truth and the upstream conversation
### Stage 0 — Ground truth and the upstream conversation — PARTLY LANDED
No production code. Two deliverables.
The wire half landed on 2026-09-18; the live-coordinator half is blocked on §7.
**Landed: contract vectors from cordn's own code.** `quartz/tools/cordn-vector-gen` generates
`resources/cordn/coordinator-contracts.json` from **`@cordn/core`** (MIT) — the package their
reference coordinator and client both import. It hands each payload *we* send to *their* zod
schema, so a field we named wrong fails at generation time, and carries a `rejects` set per
method so a passing positive means something. `CoordinatorContractVectorTest` (15 tests) drives
`CoordinatorClient` through the real ContextVM transport and asserts the arguments the
coordinator sees equal those vectors, then replays their result shapes back through our parser;
`CordnGroupRefVectorTest` (5 tests) checks `cordn1…` refs in both directions against their bech32
codec.
Two findings from doing it:
1. **Mutation-checked, because 20 tests passing on the first run means nothing on its own.**
Making a first-ever fetch send `after = 0` instead of omitting it, and flipping the group-ref
TLV emission to ascending order, each killed exactly one test. The TLV mutation was caught
*only* by the encode-direction test — decoders accept any order by spec, so a decode-only
suite would have shipped that divergence.
2. **The `kp_publish` legacy field is read-only.** Their current schema rejects
`{kp_ref, keyPackageBase64}`, so our `?? keyPackageBase64` fallback is correct for parsing old
publication events and must never be used when sending.
**Blocked: Tier B (live coordinator).** Not for want of tooling — the reference coordinator is
unlicensed. See §7.
**Not done: the upstream conversation**, which is the maintainer's to have. §4.1 no longer waits
on it (we implement both encodings); §4.2 and the missing LICENSE files are the asks worth making.
The original plan for this stage, for reference:
1. **Restore executable verification.** Get `:quartz:jvmTest` green in a clean container
(the 429 in the header) and confirm `TsMlsWelcomeInteropTest`, `MdkWelcomeInteropTest` and
@@ -966,9 +1044,19 @@ Still open in Stage 3:
## 10. Open questions
1. **§4.1 credential encoding** — who moves? Blocks Stage 2.
1. ~~**§4.1 credential encoding** — who moves?~~ **Answered 2026-09-18: nobody has to.** We
implement both. The engine has had no opinion on credential encoding since Stage 1, and the
two encodings are 32 raw bytes vs 64 ASCII bytes — mutually exclusive by length, so no leaf
can be misread as the other profile's. `BothCredentialProfilesTest` pins that, including the
`memberIdentityHex` hex-of-hex trap. The cost is §4.4 (no single group holding both clients),
which was already a deliberate profile decision.
2. **§4.2 publication payload** — will upstream give KeyPackage publication its own signed payload
or kind? Changes whether cordn KeyPackages can exist outside a coordinator.
or kind? Changes whether cordn KeyPackages can exist outside a coordinator. Still open, and now
the highest-value ask, since §4.1 no longer needs one.
2b. **Will upstream license the coordinator?** `packages/coordinator`, `packages/server` and
`packages/test-utils` carry no LICENSE (§7). Until they do, Tier B cannot be built on them.
Cheap for upstream to fix and it unblocks the only remaining verification tier that needs
their code.
3. **Is the goal interop or a second transport?** "Amethyst can talk to cordn users" and "Amethyst
supports coordinator-backed groups" are different products. The second is strictly less work
(no §4.1 dependency for group creation among Amethyst users) and strictly less valuable.
@@ -0,0 +1,386 @@
{
"_comment": "Generated by quartz/tools/cordn-vector-gen from @cordn/core (MIT). Every payload here was validated by cordn's own zod schemas / bech32 codec. Do not hand-edit.",
"generator": {
"cordnCore": "0.5.5"
},
"methods": {
"publishKeyPackage": "kp_publish",
"listAvailableKeyPackages": "kp_list",
"consumeKeyPackage": "kp_take",
"removeKeyPackages": "kp_remove",
"fetchPendingWelcomes": "welcome_take",
"storeWelcome": "welcome_store",
"storeJoinRequest": "join_request_store",
"fetchManyPendingJoinRequests": "join_request_take_many",
"postGroupMessage": "msg_post",
"fetchManyGroupMessages": "msg_fetch_many",
"subscribeManyGroupMessages": "msg_sub_many"
},
"contracts": {
"kp_publish": {
"input": {
"kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f",
"kp_64": "AAECAwQFBgc="
},
"output": {
"kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f",
"last_resort": false,
"at": 1757000000
},
"rejects": [
{
"kp_64": "AAECAwQFBgc="
},
{
"kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f",
"keyPackageBase64": "AAECAwQFBgc="
}
]
},
"kp_list": {
"input": {},
"output": {
"keyPackages": [
{
"pk": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f",
"last_resort": false,
"at": 1757000000
},
{
"pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e",
"last_resort": true,
"at": 1757000100
}
]
},
"rejects": [
{
"keyPackages": [
{
"pk": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f",
"at": 1757000000
}
]
}
]
},
"kp_take": {
"input": {
"id": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f"
},
"output": {
"keyPackage": {
"pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e",
"last_resort": true,
"at": 1757000100,
"event": {
"id": "0000000000000000000000000000000000000000000000000000000000000000",
"pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"created_at": 1757000100,
"kind": 25910,
"tags": [
[
"p",
"cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
]
],
"content": "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"kp_publish\",\"arguments\":{\"kp_ref\":\"2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e\",\"kp_64\":\"AAECAwQFBgc=\"}}}",
"sig": "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
}
}
},
"emptyOutput": {
"keyPackage": null
},
"rejects": [
{
"keyPackage": {
"pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e",
"last_resort": true,
"at": 1757000100
}
}
]
},
"kp_remove": {
"input": {
"kp_refs": [
"1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f",
"2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e"
]
},
"output": {
"kp_refs": [
"1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f"
]
},
"rejects": [
{
"kp_refs": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f"
}
]
},
"welcome_take": {
"input": {},
"inputWithConsumed": {
"consumed": [
{
"kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f",
"at": 1757000000
}
]
},
"output": {
"welcomes": [
{
"kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f",
"welcome_64": "V2VsY29tZQ==",
"at": 1757000200,
"after": 12
},
{
"kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e",
"welcome_64": "V2VsY29tZTI=",
"at": 1757000300
}
]
},
"rejects": [
{
"consumed": [
{
"kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f"
}
]
}
]
},
"welcome_store": {
"input": {
"target_pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e",
"welcome_64": "V2VsY29tZQ==",
"after": 12
},
"inputWithoutAfter": {
"target_pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e",
"welcome_64": "V2VsY29tZQ=="
},
"output": {
"at": 1757000200
},
"rejects": [
{
"kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e",
"welcome_64": "V2VsY29tZQ=="
}
]
},
"join_request_store": {
"input": {
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21",
"kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f"
},
"output": {
"at": 1757000400
},
"rejects": [
{
"kp_ref": "1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f1f"
}
]
},
"join_request_take_many": {
"input": {
"groups": [
{
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21"
}
]
},
"inputWithConsumed": {
"groups": [
{
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21"
}
],
"consumed": [
{
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21",
"pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"at": 1757000400
}
]
},
"output": {
"requests": [
{
"pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e",
"at": 1757000400,
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21"
}
]
},
"rejects": [
{
"requests": [
{
"pk": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"kp_ref": "2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e2e",
"at": 1757000400
}
]
},
{
"groups": [
"6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21"
]
}
]
},
"msg_post": {
"input": {
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21",
"msg_64": "c2VhbGVk"
},
"output": {
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21",
"cursor": 42,
"at": 1757000500
},
"rejects": [
{
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21"
}
]
},
"msg_fetch_many": {
"input": {
"groups": [
{
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21"
}
]
},
"inputWithCursor": {
"groups": [
{
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21",
"after": 42
}
]
},
"output": {
"messages": [
{
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21",
"cursor": 43,
"msg_64": "c2VhbGVkLTE=",
"at": 1757000600
},
{
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21",
"cursor": 44,
"msg_64": "c2VhbGVkLTI=",
"at": 1757000700
}
]
},
"rejects": [
{
"groups": [
{
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21",
"after": "42"
}
]
}
]
},
"msg_sub_many": {
"input": {
"groups": [
{
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21",
"after": 42
}
]
},
"output": {
"subscribed": true,
"groups": [
"6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21"
]
},
"streamFragment": {
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21",
"cursor": 45,
"msg_64": "c2VhbGVkLTM=",
"at": 1757000800
},
"rejects": [
{
"subscribed": false,
"groups": [
"6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21"
]
}
]
}
},
"groupRefs": [
{
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21",
"encoded": "cordn1qqjrvep3vccxvdnp95exzvm9956xvvnr95ukzvty95mkxdnzx4jngepnvyerz8f868g"
},
{
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21",
"coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"encoded": "cordn1qysvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenqqysmxgvtxxpnrvcfdxfsnxefdx3nryced89snzepdxa3nvc34v56xgvmpxgcs59pdwr"
},
{
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21",
"coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"relays": [
"wss://relay.example.com/"
],
"encoded": "cordn1qgv8wumn8ghj7un9d3shjtn90psk6urvv5hxxmmd9uqjpnxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvqqjrvep3vccxvdnp95exzvm9956xvvnr95ukzvty95mkxdnzx4jngepnvyerzxaqmx8"
},
{
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21",
"coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"relays": [
"wss://relay.example.com/",
"wss://relay2.example.com/"
],
"encoded": "cordn1qgv8wumn8ghj7un9d3shjtn90psk6urvv5hxxmmd9uppjamnwvaz7tmjv4kxz7fj9ejhsctdwpkx2tnrdakj7qfqenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxvenxqqfpkvsckvvrxxesj6vnpxdjj6drxxf3j6wtpx9jz6dmrxe3r2ef5vsekzv33f7g84r"
},
{
"gid": "a",
"encoded": "cordn1qqqkzhjmr98"
},
{
"gid": "grupo-café-éàü",
"encoded": "cordn1qqfxwun4wphj6cmpvmp6jtwr48p6psauqarqhy"
}
],
"groupRefUppercase": {
"gid": "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21",
"coordinatorPubkey": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"encoded": "CORDN1QYSVENXVENXVENXVENXVENXVENXVENXVENXVENXVENXVENXVENXVENQQYSMXGVTXXPNRVCFDXFSNXEFDX3NRYCED89SNZEPDXA3NVC34V56XGVMPXGCS59PDWR"
},
"groupRefRejects": [
"cordn1qqqqq",
"nostr1qqqqq",
"CORDN1QQQQQ",
"cordn",
""
]
}
@@ -70,11 +70,23 @@ class CordnFixtureCoordinator(
val method: String,
/** The caller pubkey the coordinator learned, via CEP-16 `_meta`. */
val callerPubKey: HexKey?,
/** Exactly the arguments object that arrived, for wire-shape assertions. */
val arguments: JsonObject = JsonObject(emptyMap()),
)
/** Every call seen, with who made it. The privacy surface, made observable. */
val calls = mutableListOf<Call>()
/**
* Answers keyed by tool name that replace this fixture's own bookkeeping,
* served verbatim as `structuredContent`.
*
* For replaying a result recorded from another implementation: the fixture
* models the coordinator well enough to drive a client, but a result it
* composed itself only proves we agree with ourselves.
*/
val scriptedResults = mutableMapOf<String, JsonObject>()
private val welcomes = mutableListOf<JsonObject>()
private val joinRequests = mutableListOf<JsonObject>()
private val messages = mutableMapOf<String, MutableList<JsonObject>>()
@@ -139,7 +151,9 @@ class CordnFixtureCoordinator(
?.get("clientPubkey")
?.jsonPrimitive
?.content
calls += Call(name, caller)
calls += Call(name, caller, args)
scriptedResults[name]?.let { return success(id, it) }
val structured =
when (name) {
@@ -264,14 +278,19 @@ class CordnFixtureCoordinator(
else -> buildJsonObject {}
}
return JsonRpcSuccess(
return success(id, structured)
}
private fun success(
id: JsonRpcId,
structured: JsonObject,
) = JsonRpcSuccess(
id,
buildJsonObject {
put("content", buildJsonArray {})
put(CoordinatorFields.STRUCTURED_CONTENT, structured)
},
)
}
/** The messages each requested group has after its cursor. */
fun after(args: JsonObject): List<JsonObject> =
@@ -0,0 +1,139 @@
/*
* 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.cordn.interop
import com.vitorpamplona.quartz.cordn.groups.CordnCredential
import com.vitorpamplona.quartz.cordn.groups.CordnGroupPolicy
import com.vitorpamplona.quartz.cordn.spec01GroupMetadata.CordnGroupMetadata
import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy
import com.vitorpamplona.quartz.mls.group.MlsGroup
import com.vitorpamplona.quartz.mls.tree.Credential
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
/**
* One engine, both credential encodings, side by side.
*
* §4.1 of `quartz/plans/2026-09-17-cordn-interop.md` records a hard
* incompatibility: Marmot writes a Nostr pubkey into a BasicCredential as the
* **raw 32 bytes**, cordn as **64 ASCII bytes of lowercase hex**. The decision
* was to implement both rather than wait for either ecosystem to move, which
* only works if the two profiles cannot be confused for one another — a leaf
* read under the wrong binding must come back as "not mine", never as a
* plausible-looking wrong pubkey.
*
* That is what this pins. It is cheap to get wrong in a way no single-binding
* test would notice: `MlsGroup.memberIdentityHex` hex-encodes credential bytes,
* so run over a cordn leaf it returns 128 characters of hex-of-hex — a string
* that looks like a pubkey, compares unequal to every real one, and would
* quietly drop a member from a UI list.
*/
class BothCredentialProfilesTest {
private val aliceHex = "aa".repeat(32)
private val bobHex = "bb".repeat(32)
private fun marmotGroup() = MlsGroup.create(aliceHex.hexToByteArray(), policy = MarmotGroupPolicy)
private fun cordnGroup() =
MlsGroup.create(
identity = CordnCredential.of(aliceHex).identity,
policy = CordnGroupPolicy,
initialExtensions = listOf(CordnGroupMetadata(name = "both profiles").toExtension()),
)
@Test
fun `the two encodings cannot collide, by length alone`() {
val marmot = Credential.Basic(aliceHex.hexToByteArray())
val cordn = CordnCredential.of(aliceHex)
assertEquals(32, marmot.identity.size)
assertEquals(64, cordn.identity.size)
assertNotEquals(marmot.identity.toHexKey(), cordn.identity.toHexKey())
// The disambiguation is structural, not heuristic: cordn's reader wants
// exactly 64 bytes, Marmot's wants exactly 32, and no byte string is
// both. Neither binding needs to guess which profile a leaf belongs to.
assertNull(CordnCredential.identityOrNull(marmot), "a Marmot credential must not read as a cordn identity")
}
@Test
fun `a cordn credential round-trips through its own reader`() {
assertEquals(aliceHex, CordnCredential.identityOrNull(CordnCredential.of(aliceHex)))
assertContentEquals(aliceHex.encodeToByteArray(), CordnCredential.of(aliceHex).identity)
}
@Test
fun `both groups run on the same engine at once`() {
// Not a formality: since Stage 1 the profile arrives as a constructor
// argument, so a leaked default or a shared mutable would show up as
// one group adopting the other's rules.
val marmot = marmotGroup()
val cordn = cordnGroup()
assertEquals(0L, marmot.epoch)
assertEquals(0L, cordn.epoch)
// Each group reports its own creator under its own encoding.
assertEquals(setOf(aliceHex), CordnCredential.memberIdentities(cordn))
assertEquals(aliceHex, marmot.memberIdentityHex(0))
// And a message in each still opens.
listOf(marmot, cordn).forEach { group ->
val sealed = group.encrypt("hello".encodeToByteArray())
assertEquals("hello", group.decrypt(sealed).content.decodeToString())
}
}
@Test
fun `reading a cordn leaf with the raw-bytes helper gives hex-of-hex, not a pubkey`() {
// The trap, made explicit so nobody "fixes" CordnCredential.membersOf
// back into memberIdentityHex. 64 ASCII bytes hex-encode to 128 chars.
val cordn = cordnGroup()
assertEquals(128, cordn.memberIdentityHex(0)?.length)
assertNotEquals(aliceHex, cordn.memberIdentityHex(0))
assertEquals(aliceHex, CordnCredential.identityOrNull(cordn.members().first { it.first == 0 }.second))
}
@Test
fun `each profile admits a joiner carrying its own encoding`() {
val cordn = cordnGroup()
val bobsKeyPackage = cordnGroup().createKeyPackage(CordnCredential.of(bobHex).identity, ByteArray(0))
cordn.addMember(bobsKeyPackage.keyPackage.toTlsBytes())
assertEquals(setOf(aliceHex, bobHex), CordnCredential.memberIdentities(cordn))
val marmot = marmotGroup()
val bobsMarmotKeyPackage = marmotGroup().createKeyPackage(bobHex.hexToByteArray(), ByteArray(0))
marmot.addMember(bobsMarmotKeyPackage.keyPackage.toTlsBytes())
assertEquals(bobHex, marmot.memberIdentityHex(1))
// ...and the joiner each admitted is invisible to the other binding.
assertNull(CordnCredential.identityOrNull(marmot.members().first { it.first == 1 }.second))
}
}
@@ -0,0 +1,339 @@
/*
* 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.cordn.interop
import com.vitorpamplona.quartz.contextvm.cep04Encryption.CvmGiftWrap
import com.vitorpamplona.quartz.contextvm.cep04Encryption.EncryptionMode
import com.vitorpamplona.quartz.contextvm.fixture.CvmFixtureServer
import com.vitorpamplona.quartz.contextvm.fixture.InMemoryRelayPool
import com.vitorpamplona.quartz.contextvm.mcp.CvmMcpClient
import com.vitorpamplona.quartz.contextvm.transport.CvmTransport
import com.vitorpamplona.quartz.contextvm.transport.DualSigner
import com.vitorpamplona.quartz.cordn.fixture.CordnFixtureCoordinator
import com.vitorpamplona.quartz.cordn.spec00Coordinator.AvailableKeyPackage
import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedJoinRequestRef
import com.vitorpamplona.quartz.cordn.spec00Coordinator.ConsumedWelcomeRef
import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorClient
import com.vitorpamplona.quartz.cordn.spec00Coordinator.CoordinatorMethod
import com.vitorpamplona.quartz.cordn.spec00Coordinator.GroupMessage
import com.vitorpamplona.quartz.cordn.spec00Coordinator.JoinRequest
import com.vitorpamplona.quartz.cordn.spec00Coordinator.PendingWelcome
import com.vitorpamplona.quartz.cordn.spec00Coordinator.PostedMessage
import com.vitorpamplona.quartz.cordn.spec00Coordinator.PublishedKeyPackage
import com.vitorpamplona.quartz.cordn.spec00Coordinator.TakenKeyPackage
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.yield
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* [CoordinatorClient] against cordn's own wire contracts.
*
* `resources/cordn/coordinator-contracts.json` is generated by
* `quartz/tools/cordn-vector-gen` from **`@cordn/core`** (MIT) — the package
* the reference coordinator and client both import. Every payload in it was
* accepted by their zod schema, and every `rejects` entry was refused by it.
*
* So this suite asks the two questions our own fixture cannot: does the JSON
* we put on the wire match what their coordinator parses, and can we read what
* theirs sends back? Both directions run through the real ContextVM transport,
* so a regression in argument assembly, field naming or result parsing fails
* here rather than against a live coordinator.
*
* What it does NOT cover is the coordinator's behaviour — ordering, cursor
* assignment, admission control. That needs Tier B, which is blocked; see
* `quartz/plans/2026-09-17-cordn-interop.md` §7.
*/
class CoordinatorContractVectorTest {
private val vectors: JsonObject =
Json
.parseToJsonElement(
checkNotNull(CoordinatorContractVectorTest::class.java.getResourceAsStream("/cordn/coordinator-contracts.json")) {
"missing cordn contract vectors"
}.use { it.readBytes() }
.decodeToString(),
).jsonObject
private fun contract(method: String): JsonObject = vectors["contracts"]!!.jsonObject[method]!!.jsonObject
private fun input(
method: String,
key: String = "input",
): JsonObject = contract(method)[key]!!.jsonObject
private fun output(
method: String,
key: String = "output",
): JsonObject = contract(method)[key]!!.jsonObject
private val relays = InMemoryRelayPool()
private val serverSigner = NostrSignerInternal(KeyPair())
private val stableSigner = NostrSignerInternal(KeyPair())
private val ephemeralSigner = NostrSignerInternal(KeyPair())
private val plaintext = CvmGiftWrap(encryptionMode = EncryptionMode.DISABLED)
private val coordinator = CordnFixtureCoordinator()
private val client =
CoordinatorClient(
CvmMcpClient(
CvmTransport(
relays = relays,
signers = DualSigner(stableSigner, ephemeralSigner),
serverPubKey = serverSigner.pubKey,
crypto = plaintext,
),
),
)
private fun serve() =
CvmFixtureServer(
relays = relays,
signer = serverSigner,
crypto = plaintext,
injectClientPubkey = true,
handler = coordinator::handle,
).also { it.start() }
/** Runs [block] while pumping the fixture, since the relay callback cannot suspend. */
private suspend fun <T> driving(block: suspend () -> T): T =
coroutineScope {
val server = serve()
val work = async { block() }
while (!work.isCompleted) {
yield()
server.pump()
yield()
}
work.await()
}
/** The arguments the coordinator saw for [method], which must be its only call. */
private fun sent(method: String): JsonObject {
val calls = coordinator.calls.filter { it.method == method }
assertEquals(1, calls.size, "expected exactly one $method call, saw ${coordinator.calls.map { it.method }}")
return calls.single().arguments
}
private suspend fun scripted(
method: String,
result: JsonObject,
block: suspend () -> Unit,
) {
coordinator.scriptedResults[method] = result
driving(block)
}
// ---- what we send ----------------------------------------------------
@Test
fun `kp_publish arguments match cordn's schema`() =
runTest {
driving { client.publishKeyPackage("1f".repeat(16), "AAECAwQFBgc=") }
assertEquals(input("kp_publish"), sent("kp_publish"))
}
@Test
fun `kp_remove arguments match cordn's schema`() =
runTest {
driving { client.removeKeyPackages(listOf("1f".repeat(16), "2e".repeat(16))) }
assertEquals(input("kp_remove"), sent("kp_remove"))
}
@Test
fun `kp_take arguments match cordn's schema`() =
runTest {
scripted("kp_take", output("kp_take", "emptyOutput")) { client.takeKeyPackage("1f".repeat(16)) }
assertEquals(input("kp_take"), sent("kp_take"))
}
@Test
fun `welcome_store arguments match cordn's schema, with and without a cursor`() =
runTest {
driving {
client.storeWelcome("b".repeat(64), "2e".repeat(16), "V2VsY29tZQ==", after = 12)
}
assertEquals(input("welcome_store"), sent("welcome_store"))
coordinator.calls.clear()
driving { client.storeWelcome("b".repeat(64), "2e".repeat(16), "V2VsY29tZQ==") }
assertEquals(input("welcome_store", "inputWithoutAfter"), sent("welcome_store"))
}
@Test
fun `welcome_take omits consumed when there is nothing to retire`() =
runTest {
driving { client.takeWelcomes() }
assertEquals(input("welcome_take"), sent("welcome_take"))
coordinator.calls.clear()
driving { client.takeWelcomes(listOf(ConsumedWelcomeRef("1f".repeat(16), 1757000000L))) }
assertEquals(input("welcome_take", "inputWithConsumed"), sent("welcome_take"))
}
@Test
fun `join_request arguments match cordn's schema`() =
runTest {
val gid = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21"
driving { client.storeJoinRequest(gid, "1f".repeat(16)) }
assertEquals(input("join_request_store"), sent("join_request_store"))
coordinator.calls.clear()
driving { client.takeJoinRequests(listOf(gid)) }
assertEquals(input("join_request_take_many"), sent("join_request_take_many"))
coordinator.calls.clear()
driving {
client.takeJoinRequests(listOf(gid), listOf(ConsumedJoinRequestRef(gid, "b".repeat(64), 1757000400L)))
}
assertEquals(input("join_request_take_many", "inputWithConsumed"), sent("join_request_take_many"))
}
@Test
fun `msg_post arguments match cordn's schema`() =
runTest {
driving { client.postMessage("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", "c2VhbGVk") }
assertEquals(input("msg_post"), sent("msg_post"))
}
@Test
fun `a first fetch omits after entirely rather than sending zero`() =
runTest {
// `after` is typed as a number, so 0 is a real cursor, not "from
// the start" -- sending it would silently skip the first message.
val gid = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21"
driving { client.fetchMessages(mapOf(gid to null)) }
assertEquals(input("msg_fetch_many"), sent("msg_fetch_many"))
coordinator.calls.clear()
driving { client.fetchMessages(mapOf(gid to 42L)) }
assertEquals(input("msg_fetch_many", "inputWithCursor"), sent("msg_fetch_many"))
}
// ---- what we read ----------------------------------------------------
@Test
fun `kp_publish result parses`() =
runTest {
lateinit var seen: PublishedKeyPackage
scripted("kp_publish", output("kp_publish")) {
seen = client.publishKeyPackage("1f".repeat(16), "AAECAwQFBgc=")
}
assertEquals("1f".repeat(16), seen.keyPackageRef)
assertEquals(false, seen.lastResort)
assertEquals(1757000000L, seen.at)
}
@Test
fun `kp_list result parses, last_resort and all`() =
runTest {
lateinit var seen: List<AvailableKeyPackage>
scripted("kp_list", output("kp_list")) { seen = client.listKeyPackages() }
assertEquals(2, seen.size)
assertEquals("a".repeat(64), seen[0].pubKey)
assertEquals(false, seen[0].lastResort)
assertEquals(true, seen[1].lastResort)
assertEquals(1757000100L, seen[1].at)
}
@Test
fun `kp_take result carries the publication event, and a null one means nothing held`() =
runTest {
lateinit var taken: TakenKeyPackage
scripted("kp_take", output("kp_take")) { taken = client.takeKeyPackage("2e".repeat(16))!! }
assertEquals("b".repeat(64), taken.pubKey)
assertEquals(true, taken.lastResort)
assertEquals(25910, taken.publicationEvent.kind)
// Read back out of the signed payload, not from a sibling field.
assertEquals("AAECAwQFBgc=", taken.keyPackageBase64())
coordinator.calls.clear()
coordinator.scriptedResults.clear()
var empty: TakenKeyPackage? = null
scripted("kp_take", output("kp_take", "emptyOutput")) { empty = client.takeKeyPackage("2e".repeat(16)) }
assertNull(empty, "`keyPackage: null` means the coordinator holds nothing, not an error")
}
@Test
fun `welcome_take result parses, including the optional resume cursor`() =
runTest {
lateinit var seen: List<PendingWelcome>
scripted("welcome_take", output("welcome_take")) { seen = client.takeWelcomes() }
assertEquals(2, seen.size)
assertEquals(12L, seen[0].after)
assertNull(seen[1].after, "`after` is optional; absent must not become 0")
assertEquals("V2VsY29tZQ==", seen[0].welcomeBase64)
}
@Test
fun `join_request_take_many result parses, gid and all`() =
runTest {
lateinit var seen: List<JoinRequest>
scripted("join_request_take_many", output("join_request_take_many")) {
seen = client.takeJoinRequests(listOf("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21"))
}
assertEquals(1, seen.size)
// The single-group variant of this record has no `gid`; the many
// variant does, and we read the many one.
assertEquals("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", seen[0].gid)
assertEquals("b".repeat(64), seen[0].pubKey)
}
@Test
fun `msg_post and msg_fetch_many results parse`() =
runTest {
lateinit var posted: PostedMessage
scripted("msg_post", output("msg_post")) {
posted = client.postMessage("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21", "c2VhbGVk")
}
assertEquals(42L, posted.cursor)
coordinator.calls.clear()
coordinator.scriptedResults.clear()
lateinit var fetched: List<GroupMessage>
scripted("msg_fetch_many", output("msg_fetch_many")) {
fetched = client.fetchMessages(mapOf("6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21" to 42L))
}
assertEquals(listOf(43L, 44L), fetched.map { it.cursor })
assertEquals("c2VhbGVkLTE=", fetched[0].sealedBase64)
}
@Test
fun `every coordinator tool name matches cordn's own table`() =
runTest {
val theirs =
vectors["methods"]!!
.jsonObject.values
.map { it.toString().trim('"') }
.toSet()
val ours = CoordinatorMethod.entries.map { it.wire }.toSet()
assertEquals(theirs, ours, "the tool name set must match cordn's COORDINATOR_METHODS exactly")
assertTrue(theirs.size == 11)
}
}
@@ -0,0 +1,113 @@
/*
* 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.cordn.interop
import com.vitorpamplona.quartz.cordn.appGroupRef.CordnGroupRef
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFails
/**
* `cordn1…` refs against cordn's own bech32 codec.
*
* The strings below came out of `@cordn/core`'s `encodeGroupRef`, not out of
* ours (see `quartz/tools/cordn-vector-gen`). A group ref is the one cordn
* artifact a user copies and pastes by hand, so a divergence here is not a
* protocol nuance — it is a link that works in one client and not the other.
*
* Both directions are checked: we must decode what they produce, and produce
* byte-identical output for the same input. The second half is the one that
* catches TLV ordering, since §3 lets a decoder accept any order and would hide
* the difference.
*/
class CordnGroupRefVectorTest {
private val vectors: JsonObject =
Json
.parseToJsonElement(
checkNotNull(CordnGroupRefVectorTest::class.java.getResourceAsStream("/cordn/coordinator-contracts.json")) {
"missing cordn contract vectors"
}.use { it.readBytes() }
.decodeToString(),
).jsonObject
private fun JsonObject.text(key: String) = this[key]?.jsonPrimitive?.content
private fun JsonObject.toRef() =
CordnGroupRef(
gid = text("gid")!!,
coordinatorPubKey = text("coordinatorPubkey"),
relays = this["relays"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty(),
)
@Test
fun `we decode every ref cordn encodes`() {
val cases = vectors["groupRefs"]!!.jsonArray.map { it.jsonObject }
assertEquals(6, cases.size, "the vector file lost cases")
cases.forEach { case ->
val decoded = CordnGroupRef.decode(case.text("encoded")!!)
assertEquals(case.toRef(), decoded, "decoding ${case.text("encoded")}")
}
}
@Test
fun `we encode byte-identically to cordn`() {
vectors["groupRefs"]!!.jsonArray.map { it.jsonObject }.forEach { case ->
assertEquals(
case.text("encoded"),
case.toRef().encode(),
"encoding gid=${case.text("gid")}",
)
}
}
@Test
fun `an all-uppercase ref decodes, as it does for cordn`() {
// Bech32 forbids mixed case but permits uppercase, and their
// `decodeGroupRef` accepts it even though their `isGroupRef` screen
// does not. A ref pasted in caps has to keep working.
val case = vectors["groupRefUppercase"]!!.jsonObject
assertEquals(case.toRef(), CordnGroupRef.decode(case.text("encoded")!!))
}
@Test
fun `we refuse everything cordn refuses`() {
vectors["groupRefRejects"]!!.jsonArray.forEach {
val bad = it.jsonPrimitive.content
assertFails("must refuse ${bad.ifEmpty { "<empty>" }}") { CordnGroupRef.decode(bad) }
}
}
@Test
fun `a non-ASCII gid survives byte for byte`() {
// §4.1: the coordinator keys its cursor space on these bytes, so any
// normalisation on our side silently splits a group in two.
val case = vectors["groupRefs"]!!.jsonArray.map { it.jsonObject }.last { it.text("gid")!!.any { c -> c.code > 127 } }
val decoded = CordnGroupRef.decode(case.text("encoded")!!)
assertEquals(case.text("gid"), decoded.gid)
assertEquals(case.text("encoded"), decoded.encode())
}
}
+2
View File
@@ -0,0 +1,2 @@
node_modules/
package-lock.json
+50
View File
@@ -0,0 +1,50 @@
# cordn-vector-gen
Emits `quartz/src/commonTest/resources/cordn/coordinator-contracts.json` from
**`@cordn/core`** — the package cordn's own reference coordinator and client
both import. Consumed by `CoordinatorContractVectorTest` and
`CordnGroupRefVectorTest`.
## Why this exists
Our ts-mls fixtures (`resources/tsmls/`) cover the *crypto* layer: KeyPackages,
Welcomes, exporters, sealed payloads. They say nothing about the layer above —
the eleven coordinator tools, their argument names, which fields are optional,
and the `cordn1…` group ref. That layer had been verified only against our
reading of the spec, which is exactly the kind of agreement that holds right up
until someone runs a real coordinator.
So this generator goes the other way: it takes the payloads **we** put on the
wire and hands each one to **their** zod schema. A field we named wrong fails at
generation time. Each method also carries `rejects` — payloads their schema must
refuse — because a positive result only means something if the schema is strict
where we assume it is.
Group refs are round-tripped through their bech32 codec, and the vectors pin
both directions: we must decode what they encode, *and* encode byte-identically.
Only the second half catches TLV ordering, since the spec lets decoders accept
any order.
## Regenerating
```
cd quartz/tools/cordn-vector-gen
npm install
node generate.mjs > ../../src/commonTest/resources/cordn/coordinator-contracts.json
```
Output is deterministic — fixed actors, fixed timestamps, no randomness — so a
regeneration that changes the file means cordn changed something. Commit the
result with the reason.
## Licensing
`@cordn/core` and `@cordn/cli` are **MIT** and each ship a LICENSE file; this
generator is a dev-only dependency on the first and ships in nothing.
The rest of the `Cordn-msg/cordn` repository — `packages/coordinator`,
`packages/server`, `packages/test-utils`, and the `ghcr.io/cordn-msg/cordn`
image built from them — carries **no license file and no `license` field**, so
default copyright applies. That is why Tier B (a live coordinator round-trip)
is not wired up here and why these vectors are generated from the two licensed
packages instead. See `quartz/plans/2026-09-17-cordn-interop.md` §7.
+334
View File
@@ -0,0 +1,334 @@
// Emits cordn coordinator-contract + group-ref vectors, validated by cordn's
// OWN schemas and codecs (`@cordn/core`, MIT).
//
// The point is direction: every sample below is what OUR Kotlin client puts on
// the wire, handed to THEIR zod schema. A shape we invented that their
// coordinator would reject fails here, at generation time, instead of silently
// in production. The negative samples do the converse — they prove the schema
// is actually strict where we assume it is, so a passing positive means
// something.
//
// Usage: npm install && node generate.mjs > ../../src/commonTest/resources/cordn/coordinator-contracts.json
import {
COORDINATOR_METHODS,
publishKeyPackageInputSchema,
publishKeyPackageOutputSchema,
listAvailableKeyPackagesInputSchema,
listAvailableKeyPackagesOutputSchema,
consumeKeyPackageInputSchema,
consumeKeyPackageOutputSchema,
removeKeyPackagesInputSchema,
removeKeyPackagesOutputSchema,
fetchPendingWelcomesInputSchema,
fetchPendingWelcomesOutputSchema,
storeWelcomeInputSchema,
storeWelcomeOutputSchema,
storeJoinRequestInputSchema,
storeJoinRequestOutputSchema,
fetchManyPendingJoinRequestsInputSchema,
fetchManyPendingJoinRequestsOutputSchema,
postGroupMessageInputSchema,
postGroupMessageOutputSchema,
fetchManyGroupMessagesInputSchema,
fetchManyGroupMessagesOutputSchema,
subscribeManyGroupMessagesInputSchema,
subscribeManyGroupMessagesOutputSchema,
groupMessageSchema,
encodeGroupRef,
decodeGroupRef,
isGroupRef,
} from "@cordn/core";
import { readFileSync } from "node:fs";
const coreVersion = JSON.parse(
readFileSync(new URL("./node_modules/@cordn/core/package.json", import.meta.url), "utf8"),
).version;
// Deterministic actors. 64 lowercase hex, because that is what a cordn
// credential carries as 64 ASCII bytes (spec/00.md §13).
const ALICE = "a".repeat(64);
const BOB = "b".repeat(64);
const COORDINATOR = "c".repeat(64);
const GID = "6d1f0f6a-2a3e-4f2c-9a1d-7c6b5e4d3a21";
const KP_REF = "1f".repeat(16);
const KP_REF_2 = "2e".repeat(16);
const failures = [];
/** Validates `value` against `schema`, recording (not throwing) on mismatch. */
function accepts(label, schema, value) {
const parsed = schema.safeParse(value);
if (!parsed.success) {
failures.push(`${label}: cordn's schema REJECTED a payload we send/expect — ${JSON.stringify(parsed.error.issues)}`);
}
return value;
}
/** The converse: proves the schema really is strict about `label`. */
function rejects(label, schema, value) {
const parsed = schema.safeParse(value);
if (parsed.success) {
failures.push(`${label}: cordn's schema ACCEPTED a payload we assume it rejects`);
}
return value;
}
const contracts = {
[COORDINATOR_METHODS.publishKeyPackage]: {
input: accepts("kp_publish.input", publishKeyPackageInputSchema, {
kp_ref: KP_REF,
kp_64: "AAECAwQFBgc=",
}),
output: accepts("kp_publish.output", publishKeyPackageOutputSchema, {
kp_ref: KP_REF,
last_resort: false,
at: 1757000000,
}),
rejects: [
rejects("kp_publish.input/no-ref", publishKeyPackageInputSchema, { kp_64: "AAECAwQFBgc=" }),
rejects("kp_publish.input/legacy-field", publishKeyPackageInputSchema, {
kp_ref: KP_REF,
keyPackageBase64: "AAECAwQFBgc=",
}),
],
},
[COORDINATOR_METHODS.listAvailableKeyPackages]: {
input: accepts("kp_list.input", listAvailableKeyPackagesInputSchema, {}),
output: accepts("kp_list.output", listAvailableKeyPackagesOutputSchema, {
keyPackages: [
{ pk: ALICE, kp_ref: KP_REF, last_resort: false, at: 1757000000 },
{ pk: BOB, kp_ref: KP_REF_2, last_resort: true, at: 1757000100 },
],
}),
rejects: [
rejects("kp_list.output/missing-last_resort", listAvailableKeyPackagesOutputSchema, {
keyPackages: [{ pk: ALICE, kp_ref: KP_REF, at: 1757000000 }],
}),
],
},
[COORDINATOR_METHODS.consumeKeyPackage]: {
input: accepts("kp_take.input", consumeKeyPackageInputSchema, { id: KP_REF }),
output: accepts("kp_take.output", consumeKeyPackageOutputSchema, {
keyPackage: {
pk: BOB,
kp_ref: KP_REF_2,
last_resort: true,
at: 1757000100,
event: {
id: "0".repeat(64),
pubkey: BOB,
created_at: 1757000100,
kind: 25910,
tags: [["p", COORDINATOR]],
content: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "tools/call",
params: { name: "kp_publish", arguments: { kp_ref: KP_REF_2, kp_64: "AAECAwQFBgc=" } },
}),
sig: "0".repeat(128),
},
},
}),
// `keyPackage: null` is how the coordinator says "nothing matching" — our
// client returns null rather than raising, so pin that it is legal.
emptyOutput: accepts("kp_take.output/null", consumeKeyPackageOutputSchema, { keyPackage: null }),
rejects: [
rejects("kp_take.output/no-event", consumeKeyPackageOutputSchema, {
keyPackage: { pk: BOB, kp_ref: KP_REF_2, last_resort: true, at: 1757000100 },
}),
],
},
[COORDINATOR_METHODS.removeKeyPackages]: {
input: accepts("kp_remove.input", removeKeyPackagesInputSchema, { kp_refs: [KP_REF, KP_REF_2] }),
output: accepts("kp_remove.output", removeKeyPackagesOutputSchema, { kp_refs: [KP_REF] }),
rejects: [rejects("kp_remove.input/scalar", removeKeyPackagesInputSchema, { kp_refs: KP_REF })],
},
[COORDINATOR_METHODS.fetchPendingWelcomes]: {
// Our client omits `consumed` entirely when it has nothing to retire,
// rather than sending an empty array.
input: accepts("welcome_take.input/empty", fetchPendingWelcomesInputSchema, {}),
inputWithConsumed: accepts("welcome_take.input/consumed", fetchPendingWelcomesInputSchema, {
consumed: [{ kp_ref: KP_REF, at: 1757000000 }],
}),
output: accepts("welcome_take.output", fetchPendingWelcomesOutputSchema, {
welcomes: [
{ kp_ref: KP_REF, welcome_64: "V2VsY29tZQ==", at: 1757000200, after: 12 },
{ kp_ref: KP_REF_2, welcome_64: "V2VsY29tZTI=", at: 1757000300 },
],
}),
rejects: [
rejects("welcome_take.input/consumed-without-at", fetchPendingWelcomesInputSchema, {
consumed: [{ kp_ref: KP_REF }],
}),
],
},
[COORDINATOR_METHODS.storeWelcome]: {
input: accepts("welcome_store.input", storeWelcomeInputSchema, {
target_pk: BOB,
kp_ref: KP_REF_2,
welcome_64: "V2VsY29tZQ==",
after: 12,
}),
inputWithoutAfter: accepts("welcome_store.input/no-after", storeWelcomeInputSchema, {
target_pk: BOB,
kp_ref: KP_REF_2,
welcome_64: "V2VsY29tZQ==",
}),
output: accepts("welcome_store.output", storeWelcomeOutputSchema, { at: 1757000200 }),
rejects: [
rejects("welcome_store.input/no-target", storeWelcomeInputSchema, {
kp_ref: KP_REF_2,
welcome_64: "V2VsY29tZQ==",
}),
],
},
[COORDINATOR_METHODS.storeJoinRequest]: {
input: accepts("join_request_store.input", storeJoinRequestInputSchema, { gid: GID, kp_ref: KP_REF }),
output: accepts("join_request_store.output", storeJoinRequestOutputSchema, { at: 1757000400 }),
rejects: [rejects("join_request_store.input/no-gid", storeJoinRequestInputSchema, { kp_ref: KP_REF })],
},
[COORDINATOR_METHODS.fetchManyPendingJoinRequests]: {
input: accepts("join_request_take_many.input", fetchManyPendingJoinRequestsInputSchema, {
groups: [{ gid: GID }],
}),
inputWithConsumed: accepts("join_request_take_many.input/consumed", fetchManyPendingJoinRequestsInputSchema, {
groups: [{ gid: GID }],
consumed: [{ gid: GID, pk: BOB, at: 1757000400 }],
}),
output: accepts("join_request_take_many.output", fetchManyPendingJoinRequestsOutputSchema, {
requests: [{ pk: BOB, kp_ref: KP_REF_2, at: 1757000400, gid: GID }],
}),
rejects: [
// The many-variant carries `gid` on every request; the single-group
// shape does not, and mixing them is the easy mistake.
rejects("join_request_take_many.output/no-gid", fetchManyPendingJoinRequestsOutputSchema, {
requests: [{ pk: BOB, kp_ref: KP_REF_2, at: 1757000400 }],
}),
rejects("join_request_take_many.input/bare-gids", fetchManyPendingJoinRequestsInputSchema, { groups: [GID] }),
],
},
[COORDINATOR_METHODS.postGroupMessage]: {
input: accepts("msg_post.input", postGroupMessageInputSchema, { gid: GID, msg_64: "c2VhbGVk" }),
output: accepts("msg_post.output", postGroupMessageOutputSchema, { gid: GID, cursor: 42, at: 1757000500 }),
rejects: [rejects("msg_post.input/no-msg", postGroupMessageInputSchema, { gid: GID })],
},
[COORDINATOR_METHODS.fetchManyGroupMessages]: {
// A first-ever fetch omits `after` rather than sending 0 — the schema
// types it as a number, and 0 would be a real cursor value.
input: accepts("msg_fetch_many.input/first", fetchManyGroupMessagesInputSchema, { groups: [{ gid: GID }] }),
inputWithCursor: accepts("msg_fetch_many.input/resume", fetchManyGroupMessagesInputSchema, {
groups: [{ gid: GID, after: 42 }],
}),
output: accepts("msg_fetch_many.output", fetchManyGroupMessagesOutputSchema, {
messages: [
{ gid: GID, cursor: 43, msg_64: "c2VhbGVkLTE=", at: 1757000600 },
{ gid: GID, cursor: 44, msg_64: "c2VhbGVkLTI=", at: 1757000700 },
],
}),
rejects: [
rejects("msg_fetch_many.input/string-cursor", fetchManyGroupMessagesInputSchema, {
groups: [{ gid: GID, after: "42" }],
}),
],
},
[COORDINATOR_METHODS.subscribeManyGroupMessages]: {
input: accepts("msg_sub_many.input", subscribeManyGroupMessagesInputSchema, {
groups: [{ gid: GID, after: 42 }],
}),
output: accepts("msg_sub_many.output", subscribeManyGroupMessagesOutputSchema, {
subscribed: true,
groups: [GID],
}),
// Each CEP-41 stream fragment is one of these, NOT a {messages:[…]} page.
streamFragment: accepts("msg_sub_many.fragment", groupMessageSchema, {
gid: GID,
cursor: 45,
msg_64: "c2VhbGVkLTM=",
at: 1757000800,
}),
rejects: [
rejects("msg_sub_many.output/subscribed-false", subscribeManyGroupMessagesOutputSchema, {
subscribed: false,
groups: [GID],
}),
],
},
};
// Group refs, round-tripped through THEIR bech32 codec.
const groupRefCases = [
{ gid: GID },
{ gid: GID, coordinatorPubkey: COORDINATOR },
{ gid: GID, coordinatorPubkey: COORDINATOR, relays: ["wss://relay.example.com/"] },
{
gid: GID,
coordinatorPubkey: COORDINATOR,
relays: ["wss://relay.example.com/", "wss://relay2.example.com/"],
},
{ gid: "a" },
{ gid: "grupo-café-éàü" },
];
const groupRefs = groupRefCases.map((ref) => {
const encoded = encodeGroupRef(ref);
const decoded = decodeGroupRef(encoded);
if (JSON.stringify(decoded) !== JSON.stringify(ref)) {
failures.push(`groupRef: their own round-trip changed ${JSON.stringify(ref)} into ${JSON.stringify(decoded)}`);
}
if (!isGroupRef(encoded)) failures.push(`groupRef: isGroupRef rejected ${encoded}`);
return { ...ref, encoded };
});
// Bech32 forbids mixed case but allows an all-uppercase form. Their
// `isGroupRef` screen rejects uppercase while `decodeGroupRef` accepts it, so
// a ref pasted in caps decodes on both sides — pin that, it is easy to get
// wrong in either direction.
const uppercaseRef = encodeGroupRef({ gid: GID, coordinatorPubkey: COORDINATOR }).toUpperCase();
if (JSON.stringify(decodeGroupRef(uppercaseRef)) !== JSON.stringify({ gid: GID, coordinatorPubkey: COORDINATOR })) {
failures.push("groupRef: their decoder did not round-trip the uppercase form");
}
const groupRefUppercase = { gid: GID, coordinatorPubkey: COORDINATOR, encoded: uppercaseRef };
// Strings their decoder must refuse. Ours has to refuse them too, or we accept
// group coordinates nobody else would.
const groupRefRejects = [
"cordn1qqqqq",
"nostr1qqqqq",
"CORDN1QQQQQ",
"cordn",
"",
];
for (const bad of groupRefRejects) {
let threw = false;
try {
decodeGroupRef(bad);
} catch {
threw = true;
}
if (!threw) failures.push(`groupRef: their decoder ACCEPTED ${JSON.stringify(bad)}, which we treat as invalid`);
}
if (failures.length > 0) {
console.error("cordn-vector-gen: refusing to emit vectors\n " + failures.join("\n "));
process.exit(1);
}
process.stdout.write(
JSON.stringify(
{
_comment:
"Generated by quartz/tools/cordn-vector-gen from @cordn/core (MIT). " +
"Every payload here was validated by cordn's own zod schemas / bech32 codec. Do not hand-edit.",
generator: { cordnCore: coreVersion },
methods: COORDINATOR_METHODS,
contracts,
groupRefs,
groupRefUppercase,
groupRefRejects,
},
null,
2,
) + "\n",
);
@@ -0,0 +1,9 @@
{
"name": "cordn-vector-gen",
"version": "0.1.0",
"type": "module",
"private": true,
"dependencies": {
"@cordn/core": "0.5.5"
}
}