diff --git a/marmotQuic/README.md b/marmotQuic/README.md index f7958a27ee..b273918274 100644 --- a/marmotQuic/README.md +++ b/marmotQuic/README.md @@ -107,23 +107,29 @@ amy marmot stream watch GID --stream-id … amy marmot stream finish GID --stream-id … --transcript-hash … --chunk-count N "hello" ``` -## Not wired into the app +## Advertised, but not started The implementation is complete and tested, and nothing in the app starts it. -Nothing in the deployed network publishes agent text stream previews, so the -Android chat screen no longer builds a watcher and dials the brokers a kind:1200 -advertises, and our published KeyPackage no longer advertises component `0x8006` -or the `receive`/`send`/`fanout` role capabilities. A capability is a standing -promise to every peer that reads the KeyPackage; making one for a path nobody -exercises costs something and buys nothing. +Those are two separate things, and conflating them broke interop once. Our +KeyPackage **does** advertise component `0x8006` and the `0xF2D1` receive role, +because the reference client installs `agent-text-stream.quic.v1` with +`required_member_roles = receive` into the required set of **every group it +creates**, and refuses an invitee whose leaf omits either. Dropping the +advertisement on the grounds that "nothing publishes previews" made an Amethyst +user un-addable to any group a White Noise user started — the traffic claim was +right, the capability claim was not. A capability says "this client can handle +it", never "this group uses it". -What that leaves: the codecs, this module, the CLI (`amy marmot stream …`) and -the interop tests all still work and still run. Turning the feature back on is -re-adding `AppComponentIds.AGENT_TEXT_STREAM_QUIC_V1` to -`CurrentProfileGroupFactory.SUPPORTED_COMPONENTS`, the three roles to -`MlsGroup.currentProfileLeafCapabilities()`, and the watcher to -`MarmotGroupChatView`. +What we do NOT advertise is `send` (`0xF2D2`) and `fanout` (`0xF2D4`): we can be +shown a preview, we do not originate one. A group that requires either refuses +us, and `CurrentProfileWelcomeTest` asserts that refusal so widening the default +stays a deliberate decision. + +What is not started: the Android chat screen builds no watcher and dials no +broker a kind:1200 advertises. The codecs, this module, the CLI +(`amy marmot stream …`) and the interop tests all still work and still run. +Turning the live path on is re-adding the watcher to `MarmotGroupChatView`. ## Not done diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt index da7bc32fd5..a8661a747b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactory.kt @@ -84,6 +84,13 @@ object CurrentProfileGroupFactory { AppComponentIds.ACCOUNT_IDENTITY_PROOF_V2, AppComponentIds.GROUP_ENCRYPTED_MEDIA_V2, AppComponentIds.GROUP_LIFECYCLE_V1, + // `0x8006` agent-text-stream-QUIC is a SUPPORT claim, not a + // running service. Nothing in the app dials a broker — see + // `marmotQuic/README.md` — but the reference client puts this + // component into the required set of every group it creates and + // refuses an invitee whose KeyPackage omits it. Dropping it here + // makes an Amethyst user un-addable to any group they start. + AppComponentIds.AGENT_TEXT_STREAM_QUIC_V1, ) /** A leaf keypair plus the account proof authorizing it. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt index 68e0ce27eb..c8a513f715 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mls/group/MlsGroup.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamCrypto import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1 +import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter @@ -3432,8 +3433,14 @@ class MlsGroup private constructor( * * `0xF2D1` is the agent-text-stream RECEIVE role, for the same reason: * a group carrying component `0x8006` with `required_member_roles` - * naming `receive` refuses a leaf that does not advertise it. We stop - * at receive — see `CurrentProfileGroupFactory.SUPPORTED_COMPONENTS`. + * naming `receive` refuses a leaf that does not advertise it. The + * reference client puts exactly that policy into EVERY group it + * creates, so without this line an Amethyst KeyPackage cannot be + * invited into one at all. + * + * We stop at receive. `send` and `fanout` are not here because we do + * not originate previews from the app, and a capability is a standing + * promise rather than a hedge. */ fun currentProfileLeafCapabilities(): Capabilities = Capabilities( @@ -3441,21 +3448,7 @@ class MlsGroup private constructor( listOf( AppDataDictionary.EXTENSION_TYPE, MarmotGroupData.EXTENSION_ID_INT, - // The agent-stream roles (`0xF2D1` receive, `0xF2D2` - // send, `0xF2D4` fanout) are deliberately NOT here. - // - // The implementation exists and stays — see - // [AgentTextStreamRoles] and the `:marmotQuic` module — - // but nothing in the deployed network uses the QUIC - // preview path, and an advertised capability is a - // standing promise to every peer that reads our - // KeyPackage. Advertising a role no one exercises buys - // nothing and commits us to answering for it; the - // reference KeyPackage in our own conformance vector - // does not advertise it either. - // - // Re-adding them is a one-line change once the feature - // is actually in use. + AgentTextStreamRoles.RECEIVE_CAPABILITY, ), proposals = listOf(APP_DATA_UPDATE_PROPOSAL_TYPE, SELF_REMOVE_PROPOSAL_TYPE), ) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt index 0a75465c1b..6c138d319b 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/CurrentProfileGroupFactoryTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.marmot.appComponents import com.vitorpamplona.quartz.TestResourceLoader import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2 +import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamRoles import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader @@ -106,26 +107,26 @@ class CurrentProfileGroupFactoryTest { assertContentEquals(ByteArray(0), kpDictionary[AppComponentIds.LAST_RESORT_KEY_PACKAGE]) // Capabilities advertise the draft extension the current profile - // needs, plus the legacy 0xF2EE group-data extension and all three - // agent-text-stream roles — the same set MDK puts on every - // KeyPackage it publishes. + // needs, plus the legacy `0xF2EE` group-data extension and the + // `0xF2D1` agent-text-stream RECEIVE role. // - // `0xF2EE` is deliberate and is NOT drift from the MDK reference: - // a legacy group REQUIRES it, and a group refuses to add a leaf - // that does not advertise what it requires, so without it a - // current-profile KeyPackage would be un-addable to every legacy - // group that already exists. + // Both extras are there for the same reason, and it is not a + // hedge: a group refuses to add a leaf that does not advertise + // what it requires. A legacy group REQUIRES `0xF2EE`, so without + // it a current-profile KeyPackage would be un-addable to every + // legacy group that already exists. And the reference client puts + // `0x8006` with `required_member_roles = receive` into EVERY group + // it creates, so without `0xF2D1` an Amethyst user cannot be + // invited into one at all. // - // The agent-stream roles are deliberately absent. Advertising more - // than a group requires is harmless to that group but is not free: - // it is a standing claim to every peer that reads this KeyPackage, - // and nothing in the deployed network uses the QUIC preview path. - // The reference KeyPackage in `mls/marmot-current-profile.json` - // does not advertise `0x8006` either. + // `send` and `fanout` stay absent: we can be shown a preview, we + // do not originate one, and a capability is a standing promise to + // every peer that reads this KeyPackage. assertEquals( listOf( AppDataDictionary.EXTENSION_TYPE, MarmotGroupData.EXTENSION_ID_INT, + AgentTextStreamRoles.RECEIVE_CAPABILITY, ), kp.leafNode.capabilities.extensions, ) diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CurrentProfileWelcomeTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CurrentProfileWelcomeTest.kt index 65bb2887cd..5fb12b322d 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CurrentProfileWelcomeTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/marmot/mls/group/CurrentProfileWelcomeTest.kt @@ -156,26 +156,61 @@ class CurrentProfileWelcomeTest { } /** - * Our published KeyPackage advertises NO agent-stream role, and is - * therefore refused by a group that requires one. + * Our published KeyPackage satisfies the policy the reference client puts + * on EVERY group it creates. * - * That refusal is the deliberate cost of not advertising, so it is asserted - * rather than discovered: the implementation is still here and still - * tested, but a capability is a standing promise to every peer that reads - * the KeyPackage, and we do not make one for a path nothing uses. If this - * test starts failing because the default advertises a role again, that is - * a decision to take on purpose, not a drift to absorb. + * `userToAgentDefault()` is not a hypothetical: `create_group` in the + * reference installs exactly it, requiring `receive` of every invitee, and + * refuses a KeyPackage that does not advertise `0xF2D1`. So this is the + * assertion that decides whether an Amethyst user can be invited into a + * group started by that client at all — it failed for real once, when the + * role was dropped from the default leaf. */ @Test - fun ourDefaultLeafAdvertisesNoStreamRoleAndIsRefusedByAGroupThatNeedsOne() = + fun ourDefaultLeafIsAdmittedByTheReferenceStreamPolicy() = runBlocking { val group = aGroup(AgentTextStreamQuicPolicyV1.userToAgentDefault()) val invitee = CurrentProfileGroupFactory.createKeyPackage(signer(0x77)) assertTrue( invitee.keyPackage.leafNode.capabilities.extensions - .none { it in AgentTextStreamRoles.ALL_CAPABILITIES }, - "the default leaf must carry no agent-stream role, got ${invitee.keyPackage.leafNode.capabilities.extensions}", + .contains(AgentTextStreamRoles.RECEIVE_CAPABILITY), + "the default leaf must advertise receive, got ${invitee.keyPackage.leafNode.capabilities.extensions}", + ) + + group.proposeAdd(invitee.keyPackage.toTlsBytes()) + val welcome = assertNotNull(group.commit().welcomeBytes) + val joined = MlsGroup.processWelcome(welcome, invitee) + assertEquals(nostrGroupId.toHexKey(), joined.currentNostrGroupId()) + } + + /** + * Where we stop: a group requiring `send` refuses our default leaf. + * + * Advertising `receive` and not `send` is a deliberate line — we can be + * shown a preview, we do not originate one — and the refusal is its cost. + * Asserted rather than discovered, so that widening the default is a + * decision someone takes on purpose. + */ + @Test + fun ourDefaultLeafIsRefusedByAGroupThatRequiresSend() = + runBlocking { + val group = + aGroup( + AgentTextStreamQuicPolicyV1( + requiredMemberRoles = AgentTextStreamRoles.RECEIVE or AgentTextStreamRoles.SEND, + allowedMemberRoles = AgentTextStreamRoles.MASK, + maxPlaintextFrameLen = 4096, + replayTtlSecs = 0, + paddingBucketBytes = 0, + ), + ) + val invitee = CurrentProfileGroupFactory.createKeyPackage(signer(0x78)) + + assertTrue( + invitee.keyPackage.leafNode.capabilities.extensions + .none { it == AgentTextStreamRoles.SEND_CAPABILITY }, + "the default leaf must not advertise send", ) group.proposeAdd(invitee.keyPackage.toTlsBytes()) @@ -205,11 +240,12 @@ class CurrentProfileWelcomeTest { * A current-profile KeyPackage whose leaf advertises exactly [roles] on top * of the default capability set. * - * The default set no longer carries any agent-stream role, so these tests - * build the leaf they need instead of relying on it. That is the right - * shape regardless: a test that asserted the gate through OUR default was - * really asserting the default, and stopped testing the gate the moment the - * default changed — which is exactly what happened. + * The default set carries `receive` and nothing beyond it, so a test that + * needs `send` or `fanout` builds the leaf it needs instead of relying on + * the default. That is the right shape regardless: a test that asserted the + * gate through OUR default was really asserting the default, and stopped + * testing the gate the moment the default changed — which is exactly what + * happened, twice, in both directions. */ private suspend fun keyPackageAdvertising( signer: NostrSignerInternal,