docs(mls): record the engine boundary; last hardcoded profile bits

Three leftovers from the extraction, plus the documentation it needs to
survive.

`KeyPackageRotationManager` still built its leaf capabilities from bare hex
(`0x000A`, `0xF2EE`, `0x000A`) with the two meanings of `0x000A` -- last_resort
as an extension, self_remove as a proposal -- distinguishable only by which
list they were in. That set is now `MarmotCapabilities.mipKeyPackageLeaf()`,
beside the other two profiles, with the ordering marked load-bearing: those
bytes go into published KeyPackages and define KeyPackageBundleStore's v4
snapshot format.

`MarmotManager` and `MarmotConvergenceEngine` still spelled
`exporterSecret("marmot", "group-event", 32)` literally at three call sites, so
the binding's key derivation was stated in four places. They read it off
`MarmotGroupPolicy.commitExporter` now, which is where the engine reads it too.

`CurrentProfileWelcomeTest` and `CommitPreservesLeafIdentityTest` sat in the
`mls/` test tree with ten and three Marmot imports between them; they test
Marmot's current profile, so they move to `marmot/appComponents/`.

The README states the invariant as a command you can run, and scopes it
honestly: shipped code only. Two tests under `mls/components/` do decode real
Marmot components, because an interop test is worth more against payloads that
actually exist -- a fixture is data, an import in the engine is a dependency.
It also writes down the three things a second binding has to know, the one
that will bite (a policy is behaviour, not state, so a restore that forgets it
silently drops the rules), and why the default is permissive rather than
closed.

The plan's Stage 1 is marked landed with what it cost and what Stage 3 still
owes: MlsGroupManager names `nostrGroupId` 147 times, so cordn needs its own
manager over the same MlsGroup rather than reusing that one. §5.1's coupling
measurements are marked superseded rather than deleted -- they are what the
stage was scoped against.

Verified: :quartz:jvmTest 5082, :commons:jvmTest 2202, both green;
:quic, :cli, :marmotBench and :amethyst all compile.

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 16:32:05 +00:00
parent 759a39c9af
commit d25b36704d
17 changed files with 196 additions and 59 deletions
@@ -207,8 +207,8 @@ import com.vitorpamplona.quartz.experimental.profileGallery.hash
import com.vitorpamplona.quartz.experimental.profileGallery.image
import com.vitorpamplona.quartz.experimental.profileGallery.mimeType
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicTransport
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -23,9 +23,9 @@ package com.vitorpamplona.amethyst.cli.stores
import com.vitorpamplona.amethyst.cli.SecureFileIO
import com.vitorpamplona.amethyst.commons.util.deleteOrWarn
import com.vitorpamplona.quartz.marmot.MarmotIngestDedupStore
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -51,6 +51,7 @@ import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotGroupSnapshot
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotMessageEdit
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemEvent
import com.vitorpamplona.quartz.marmot.foundation.appEvents.MarmotSystemRowDiff
import com.vitorpamplona.quartz.marmot.groups.MarmotGroupPolicy
import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.groups.MlsGroupManager
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
@@ -90,14 +91,14 @@ import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import com.vitorpamplona.quartz.utils.sha256.sha256
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Central coordinator for Marmot MLS group messaging.
@@ -327,9 +328,11 @@ class MarmotManager(
): ByteArray? =
try {
val preCommitKey =
MarmotGroupPolicy.commitExporter.let { exporter ->
MlsGroup
.restore(obligation.priorState)
.exporterSecret("marmot", "group-event".encodeToByteArray(), 32)
.restore(obligation.priorState, MarmotGroupPolicy)
.exporterSecret(exporter.label, exporter.context, exporter.length)
}
GroupEventEncryption.decrypt(event.content, preCommitKey)
} catch (e: Exception) {
Log.w(
@@ -27,10 +27,10 @@ import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.PreviewStat
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicException
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicStream
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.transport.MarmotQuicTransport
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.CompletableDeferred
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils
import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -20,10 +20,10 @@
*/
package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -20,8 +20,8 @@
*/
package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -20,9 +20,9 @@
*/
package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.nip01Core.core.Event
// In-memory stand-ins for the durable stores a MarmotManager needs.
@@ -21,9 +21,9 @@
package com.vitorpamplona.marmotbench
import com.vitorpamplona.amethyst.commons.marmot.MarmotPublisher
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.marmot.groups.MarmotMessageStore
import com.vitorpamplona.quartz.marmot.groups.MlsGroupStateStore
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
import com.vitorpamplona.quartz.nip01Core.core.Event
// In-memory stores, matching the commons test doubles byte for byte.
+58 -25
View File
@@ -1,9 +1,10 @@
# Cordn interop: extract the MLS core, then add a second binding
Status: Stage 2 landed. `:contextvm` implements the core spec plus all 12 CEPs on the client
side, with the Tier C fixture server, at 172 tests. Stages 0 (cordn-side vectors), 1 (extract the
MLS engine) and 3-4 (the cordn binding and app integration) are open. Stage 3 is the one gated on
the §4.1 upstream decision.
Status: Stages 1 and 2 landed. The RFC 9420 engine is `quartz/…/mls/` and imports nothing from
`marmot/` — a binding supplies its rules through `MlsGroupPolicy`. `:contextvm` implements the
core spec plus all 12 CEPs on the client side, with the Tier C fixture server, at 172 tests.
Stages 0 (cordn-side vectors) and 3-4 (the cordn binding and app integration) are open; Stage 3
is the one gated on the §4.1 upstream decision.
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;
@@ -201,7 +202,11 @@ wire format. There is nothing to implement against. Do not attempt it.
### 5.1 The engine, and how Marmot-clean it is
`quartz/…/marmot/mls/` is **11,964 LOC**. Measured coupling to the Marmot layer:
**Superseded by Stage 1, which landed.** The measurements below are what the engine looked like
before the extraction; they are kept because they are what the stage was scoped against. The
engine is now `quartz/…/mls/` with zero `marmot/` imports.
`quartz/…/marmot/mls/` was **11,964 LOC**. Measured coupling to the Marmot layer:
- **3 files**, **10 imports** total:
- `group/MlsGroup.kt` (7): `MarmotGroupData`, `MarmotGroupState`, `AdminPolicyV1`,
@@ -637,29 +642,57 @@ No production code. Two deliverables.
**Gate:** do not start Stage 2 until §4.1 has an answer. Stage 1 is safe to do regardless.
### Stage 1 — Extract a binding-agnostic RFC 9420 engine
### Stage 1 — Extract a binding-agnostic RFC 9420 engine — LANDED
Worth doing whether or not cordn ever ships. Today the engine is one protocol's private detail;
this makes it a library.
Done in four commits. The engine is `quartz/…/mls/` and imports nothing from `quartz/…/marmot/`.
- Move `quartz/…/marmot/mls/` → `quartz/…/mls/`. Packages change; `marmot/` keeps everything
else.
- Parameterize what §5.1 lists as hardcoded: group id, credential identity bytes,
`required_capabilities`, leaf `capabilities`, and the exporter label/context all become
constructor or call-site inputs. `exporterSecret(label, context, length)` already exists —
stop calling it with a literal `"marmot"` from inside the engine.
- Push the Marmot-specific reads out to `marmot/`: `currentMarmotData()`, `currentGroupState()`,
`currentNostrGroupId()`, the `AdminPolicyV1`/`GroupLifecycleV1` hooks and the agent-text-stream
helpers. Where the engine needs a policy decision, it takes an interface; `marmot/` supplies the
Marmot implementation.
- Move `group/MarmotMessageStore.kt` out (it is named for Marmot and keyed on
`nostrGroupId` — it is a binding concern).
- Behaviour must not change. The existing MLS + Marmot test suites are the contract; they pass
unmodified except for import lines.
| What | Where it went |
| ---- | ------------- |
| The engine (28 files, 11,964 LOC) | `quartz/…/marmot/mls/` → `quartz/…/mls/` |
| `MlsGroupManager`, `MlsGroupStateStore`, `MarmotMessageStore` | → `marmot/groups/` (all keyed on `nostrGroupId`) |
| MIP-03 authorization, depletion guard, join role check, self-remove gate | → `marmot/groups/MarmotGroupPolicy` |
| Leaf + `required_capabilities` profiles | → `marmot/groups/MarmotCapabilities` |
| `currentMarmotData/GroupState/NostrGroupId`, `agentTextStreamSecret` | → `marmot/groups/MarmotGroupViews` (extension functions) |
| `last_resort_key_package` (0x0004) | → `mls/components/ComponentsList` — it is the extensions draft's, not Marmot's |
Risk: `MlsGroup.kt` is 4,505 lines and carries the convergence/lifecycle logic. Keep this stage
strictly mechanical — extraction and parameterization, no logic edits — so the diff stays
reviewable and the test suite is a real check.
**The seam is `MlsGroupPolicy`**: three hooks the engine calls where RFC 9420 defers to the
application (`authorizeCommit`, `authorizeSelfRemove`, `validateJoin`) and four values it reads
(leaf capabilities, `required_capabilities`, extra known extension types, the commit exporter
label). One argument selects a whole profile — `MlsGroup.create(id, policy = MarmotGroupPolicy)`
brings the rules, the capabilities and `MLS-Exporter("marmot", "group-event", 32)` together — so
adopting it cost one added argument per call site rather than five.
Policies receive a read-only `GroupView`, not the `MlsGroup`: a policy holding the group could
commit or rotate keys from inside the check meant to gate those things.
**The default is permissive**, which is a trade worth naming. Closed would make the engine
unusable without a policy and would push callers into writing an allow-everything one anyway.
The cost is that a group restored without its policy silently drops the binding's rules — a
policy is behaviour, not state, so it is deliberately not in `MlsGroupState`. All ten production
construction sites are in `marmot/` and all ten name it.
Two findings from doing it:
1. **`:quic` was already a second consumer, reaching through the wrong package.**
`quic/tls/TlsClient.kt` imported `quartz.marmot.mls.crypto.X25519` for its TLS 1.3 handshake —
neither Marmot nor MLS. That is the argument for this stage independent of cordn.
2. **The test suite found every site that had been relying on Marmot defaults.** The first run
after `MlsGroup.create` stopped defaulting to Marmot's profile failed 13 tests, each one a
Marmot test that had been getting a Marmot-shaped group for free. The other ~180 construction
sites kept passing on the permissive default — they are engine tests, and they now prove the
engine runs without Marmot at all.
8 tests were added for the seam itself (`MlsGroupPolicySeamTest`, `MarmotPolicySeamTest`),
including the half no existing test covered: the same group at the same state accepts the same
commit once the policy is gone. Verified by mutation — ignoring the policy in `commit()` and
re-hardcoding the exporter label each kill exactly their guarding tests.
Suite: `:quartz:jvmTest` 5074 → 5082, `:commons:jvmTest` 2202, both green.
What Stage 3 still owes: `MlsGroupManager` is keyed on `nostrGroupId` 147 times, so cordn cannot
reuse it and needs its own manager over the same `MlsGroup`. Class names were left alone in the
move — `MlsGroupManager` under `marmot/groups/` reads correctly and renaming four classes would
have churned 22 files across five modules for clarity the package path already gives.
### Stage 2 — `:contextvm` module (clean-room) — LANDED
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
import com.vitorpamplona.quartz.mls.codec.TlsWriter
import com.vitorpamplona.quartz.mls.components.AppDataDictionary
import com.vitorpamplona.quartz.mls.group.MlsGroup
import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage
import com.vitorpamplona.quartz.mls.tree.Capabilities
import com.vitorpamplona.quartz.mls.tree.Credential
import com.vitorpamplona.quartz.mls.tree.Extension
@@ -59,6 +60,24 @@ object MarmotCapabilities {
proposals = listOf(MlsGroup.SELF_REMOVE_PROPOSAL_TYPE),
)
/**
* The MIP-era leaf set as a published KeyPackage advertises it.
*
* Same as [mipLeaf] plus `0x000A` as an EXTENSION, which OpenMLS validation
* requires on a last-resort KeyPackage. Note `0x000A` appears in both lists
* meaning different things: as an extension it is `last_resort`, as a
* proposal it is `self_remove`.
*
* The order is load-bearing and must not be tidied: these bytes go into
* published KeyPackages, and `KeyPackageBundleStore`'s v4 snapshot format
* is defined by them.
*/
fun mipKeyPackageLeaf(): Capabilities =
Capabilities(
extensions = listOf(MlsKeyPackage.LAST_RESORT_EXTENSION_TYPE, MARMOT_GROUP_DATA_EXTENSION_TYPE),
proposals = listOf(MlsGroup.SELF_REMOVE_PROPOSAL_TYPE),
)
/**
* Build an MLS `required_capabilities` extension that marks Marmot's
* mandatory interop set as required for all members (RFC 9420 §7.2):
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.marmot.mip00KeyPackages
import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory
import com.vitorpamplona.quartz.marmot.groups.MarmotCapabilities
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRotationManager.Companion.SNAPSHOT_VERSION
import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite
import com.vitorpamplona.quartz.mls.codec.TlsReader
@@ -30,7 +31,6 @@ import com.vitorpamplona.quartz.mls.crypto.MlsCryptoProvider
import com.vitorpamplona.quartz.mls.crypto.X25519
import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.mls.messages.MlsKeyPackage
import com.vitorpamplona.quartz.mls.tree.Capabilities
import com.vitorpamplona.quartz.mls.tree.Credential
import com.vitorpamplona.quartz.mls.tree.Extension
import com.vitorpamplona.quartz.mls.tree.LeafNode
@@ -654,18 +654,7 @@ class KeyPackageRotationManager(
encryptionKey = encryptionKey,
signatureKey = signatureKey,
credential = Credential.Basic(identity),
capabilities =
Capabilities(
extensions =
listOf(
0x000A, // LastResort (required by OpenMLS validation)
0xF2EE, // NostrGroupData (required by group's RequiredCapabilities)
),
proposals =
listOf(
0x000A, // SelfRemove (required by group's RequiredCapabilities)
),
),
capabilities = MarmotCapabilities.mipKeyPackageLeaf(),
leafNodeSource = LeafNodeSource.KEY_PACKAGE,
lifetime = Lifetime(notBefore = now, notAfter = now + KEY_PACKAGE_LIFETIME_SECONDS),
extensions = emptyList(),
@@ -423,7 +423,9 @@ class MarmotConvergenceEngine(
mutex.withLock {
contexts[groupId]?.candidateStates?.values?.mapNotNull { state ->
try {
MlsGroup.restore(state, MarmotGroupPolicy).exporterSecret("marmot", "group-event".encodeToByteArray(), 32)
MarmotGroupPolicy.commitExporter.let {
MlsGroup.restore(state, MarmotGroupPolicy).exporterSecret(it.label, it.context, it.length)
}
} catch (_: Exception) {
null
}
@@ -0,0 +1,89 @@
# `mls/` — the RFC 9420 engine
A binding-agnostic MLS (RFC 9420) implementation: TLS presentation-language
codec, HPKE, X25519/Ed25519, the ratchet tree, the key schedule, the secret
tree, message framing, proposals, commits, Welcome, and the `app_data_dictionary`
component carrier from `draft-ietf-mls-extensions-10`.
**It knows nothing about Marmot, Nostr, or cordn, and must stay that way.** The
invariant, over the shipped code:
```bash
grep -rn 'import com.vitorpamplona.quartz.marmot' \
--include='*.kt' quartz/src/{commonMain,jvmAndroid,appleMain,linuxMain}/kotlin/com/vitorpamplona/quartz/mls/
```
That must print nothing. Tests are deliberately outside it: a couple under
`mls/components/` decode real Marmot components as fixtures, because the point
of an interop test is to exercise the engine against payloads that actually
exist. A fixture is data; an import in the engine is a dependency.
It lived under `marmot/` until Stage 1 of
`quartz/plans/2026-09-17-cordn-interop.md`, and `:quic` was already importing
`crypto/X25519` from there for its TLS 1.3 handshake — a use that is neither
Marmot nor MLS, and the reason the extraction was overdue.
## Using it
```kotlin
val group = MlsGroup.create(identity) // a plain RFC 9420 group
val group = MlsGroup.create(identity, policy = MarmotGroupPolicy) // Marmot's profile
```
The default group requires nothing beyond RFC 9420: no `required_capabilities`,
no leaf capabilities (§7.2 forbids advertising DEFAULT types), no commit
exporter, and no limit on who may commit what.
## The `MlsGroupPolicy` seam
RFC 9420 says who may *send* a proposal and never who may *commit* one. Real
deployments need more — Marmot's MIP-03 names admin accounts and allows everyone
else only a self-Update or a SelfRemove — so the engine asks a policy wherever
the spec defers to the application:
| Hook | Called from | Marmot uses it for |
| ---- | ----------- | ------------------ |
| `authorizeCommit` | `commit()` and both inbound commit paths | MIP-03 proposal-set rules + admin depletion |
| `authorizeSelfRemove` | `proposeSelfRemove`, `buildSelfRemoveProposalMessage` | admin must self-demote first |
| `validateJoin` | `processWelcome` | `required_member_roles` on the agent-text-stream component |
| `defaultLeafCapabilities` | every leaf the engine builds | `0xF2EE` + `self_remove` |
| `defaultRequiredCapabilities` | `create()`, epoch 0 | the MIP-era interop set |
| `knownExtensionTypes` | GroupContextExtensions validation | `0xF2EE` |
| `commitExporter` | `CommitResult.preCommitExporterSecret` | `MLS-Exporter("marmot", "group-event", 32)` |
Three things to know before you add a binding:
1. **One argument selects a whole profile.** `policy` supplies the rules *and*
the capability defaults *and* the exporter binding, so `capabilities` and
`requiredCapabilities` default from it. Adopting a profile is one argument,
not five.
2. **A policy is behaviour, not state.** It is deliberately absent from
`MlsGroupState`, so whatever restores a group must pass the same policy it
was created with. A restore that forgets it gets a group that silently skips
the binding's rules. In Marmot only `MlsGroupManager` restores a group in
order to commit with it, which is what keeps that narrow.
3. **A policy gets a `GroupView`, not the `MlsGroup`.** A policy holding the
group could commit or rotate keys from inside the check meant to gate exactly
that.
The default is permissive rather than closed. Closed would make the engine
unusable without a policy and would push callers into writing an
allow-everything one anyway; open puts each restriction in the binding that
documents it. The cost is item 2 above.
## Where the bindings live
- `quartz/…/marmot/groups/` — `MarmotGroupPolicy`, `MarmotCapabilities`,
`MarmotGroupViews` (Marmot's reads of a GroupContext, as extension functions),
plus `MlsGroupManager` and the two stores, all keyed on `nostrGroupId`.
- `quartz/…/marmot/mipXX…/` — the Nostr event layer.
## Tests
`quartz/src/commonTest/…/mls/` runs everywhere; `quartz/src/jvmAndroidTest/…/mls/`
holds the tests needing real secp256k1/JNI. `mls/interop/` replays the RFC 9420
test vectors. `group/MlsGroupPolicySeamTest` pins the seam itself with a
recording policy; `marmot/groups/MarmotPolicySeamTest` pins that Marmot's rules
travel with `MarmotGroupPolicy` and not with the engine.
@@ -18,12 +18,13 @@
* 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.mls.group
package com.vitorpamplona.quartz.marmot.appComponents
import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds
import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
import com.vitorpamplona.quartz.mls.components.AppDataDictionary
import com.vitorpamplona.quartz.mls.group.MlsGroup
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.runBlocking
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.mls.group
package com.vitorpamplona.quartz.marmot.appComponents
import com.vitorpamplona.quartz.marmot.appComponents.CurrentProfileGroupFactory
import com.vitorpamplona.quartz.marmot.appComponents.GroupProfileV1
@@ -32,6 +32,7 @@ import com.vitorpamplona.quartz.marmot.groups.currentMarmotData
import com.vitorpamplona.quartz.marmot.groups.currentNostrGroupId
import com.vitorpamplona.quartz.mls.crypto.Ed25519
import com.vitorpamplona.quartz.mls.crypto.Ed25519KeyPair
import com.vitorpamplona.quartz.mls.group.MlsGroup
import com.vitorpamplona.quartz.mls.messages.KeyPackageBundle
import com.vitorpamplona.quartz.mls.tree.Capabilities
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray