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 # 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/` 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; 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, `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) group refs and the sync rules, verified against ts-mls in both directions **and against cordn's
are open. §4.1 turned out not to gate the binding — see Stage 3 — but remains a real own wire contracts** (`quartz/tools/cordn-vector-gen` → `resources/cordn/`). Stage 4 (app
incompatibility between the two ecosystems. 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 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; 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 - `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 confirm deployed defaults, never as an implementation source
Verification status: `:quartz:jvmTest` passes in a container at 5330 tests, covering the MLS Verification status: `:quartz:jvmTest` passes in a container, covering the MLS engine, ContextVM
engine, ContextVM and cordn, so the interop claims in §3 are execution-verified rather than and cordn, so the interop claims in §3 are execution-verified rather than read-verified.
read-verified. `:quartz:testAndroidHostTest` passes too, apart from four pre-existing failures in `:quartz:testAndroidHostTest` passes as well — the four failures this plan previously recorded as
`NostrServerTest` and `LiveNegentropyIndexStoreTest` that predate this work (confirmed by running pre-existing (`NostrServerTest`, `LiveNegentropyIndexStoreTest`) were fixed on 2026-09-18: the
them at the parent commit). 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 ## 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 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. 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 **Decision (2026-09-18): support both encodings; do not wait for an agreement.** Neither
one does. 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 ### 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 - 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. (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 🔴 **Correction (2026-09-18, verified): "everything under `Cordn-msg` is MIT" was wrong.** Only
and to implement against. 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 ## 8. What the coordinator can see
@@ -623,9 +672,38 @@ looking like a group chat.
## 9. Plan ## 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 1. **Restore executable verification.** Get `:quartz:jvmTest` green in a clean container
(the 429 in the header) and confirm `TsMlsWelcomeInteropTest`, `MdkWelcomeInteropTest` and (the 429 in the header) and confirm `TsMlsWelcomeInteropTest`, `MdkWelcomeInteropTest` and
@@ -966,9 +1044,19 @@ Still open in Stage 3:
## 10. Open questions ## 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 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 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 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. (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, val method: String,
/** The caller pubkey the coordinator learned, via CEP-16 `_meta`. */ /** The caller pubkey the coordinator learned, via CEP-16 `_meta`. */
val callerPubKey: HexKey?, 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. */ /** Every call seen, with who made it. The privacy surface, made observable. */
val calls = mutableListOf<Call>() 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 welcomes = mutableListOf<JsonObject>()
private val joinRequests = mutableListOf<JsonObject>() private val joinRequests = mutableListOf<JsonObject>()
private val messages = mutableMapOf<String, MutableList<JsonObject>>() private val messages = mutableMapOf<String, MutableList<JsonObject>>()
@@ -139,7 +151,9 @@ class CordnFixtureCoordinator(
?.get("clientPubkey") ?.get("clientPubkey")
?.jsonPrimitive ?.jsonPrimitive
?.content ?.content
calls += Call(name, caller) calls += Call(name, caller, args)
scriptedResults[name]?.let { return success(id, it) }
val structured = val structured =
when (name) { when (name) {
@@ -264,15 +278,20 @@ class CordnFixtureCoordinator(
else -> buildJsonObject {} else -> buildJsonObject {}
} }
return JsonRpcSuccess( return success(id, structured)
id,
buildJsonObject {
put("content", buildJsonArray {})
put(CoordinatorFields.STRUCTURED_CONTENT, 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. */ /** The messages each requested group has after its cursor. */
fun after(args: JsonObject): List<JsonObject> = fun after(args: JsonObject): List<JsonObject> =
args[CoordinatorFields.GROUPS]!!.jsonArray.flatMap { entry -> args[CoordinatorFields.GROUPS]!!.jsonArray.flatMap { entry ->
@@ -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"
}
}