mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
fix(marmot): advertise 0x8006 and the receive role again
Advertising a capability and running a service are different claims, and conflating them made an Amethyst user un-addable to any group a White Noise user starts. The reference client installs agent-text-stream-quic-v1 with `required_member_roles = receive` into the required component set of EVERY group it creates, then refuses an invitee whose KeyPackage omits either the component or the `0xF2D1` role — checked before negotiation, so negotiation cannot rescue it, and the invite path applies the same rule. Dropping the advertisement on the grounds that nothing in the deployed network publishes previews was right about the traffic and wrong about the capability: a capability says "this client can handle it", never "this group uses it". So the component and the receive role go back. `send` and `fanout` stay off and no watcher is started — we can be shown a preview, we do not originate one, and nothing dials a broker. Two tests had encoded the old premise, one of them asserting the refusal as if it were a feature. They now assert the rule that actually decides interop: our default leaf is admitted by the reference's own stream policy, and is refused by a group that requires `send`. The KDoc on `currentProfileLeafCapabilities` had described the role as present the whole time — it was the code that had drifted from it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq
This commit is contained in:
+19
-13
@@ -107,23 +107,29 @@ amy marmot stream watch GID --stream-id …
|
|||||||
amy marmot stream finish GID --stream-id … --transcript-hash … --chunk-count N "hello"
|
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.
|
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
|
Those are two separate things, and conflating them broke interop once. Our
|
||||||
Android chat screen no longer builds a watcher and dials the brokers a kind:1200
|
KeyPackage **does** advertise component `0x8006` and the `0xF2D1` receive role,
|
||||||
advertises, and our published KeyPackage no longer advertises component `0x8006`
|
because the reference client installs `agent-text-stream.quic.v1` with
|
||||||
or the `receive`/`send`/`fanout` role capabilities. A capability is a standing
|
`required_member_roles = receive` into the required set of **every group it
|
||||||
promise to every peer that reads the KeyPackage; making one for a path nobody
|
creates**, and refuses an invitee whose leaf omits either. Dropping the
|
||||||
exercises costs something and buys nothing.
|
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
|
What we do NOT advertise is `send` (`0xF2D2`) and `fanout` (`0xF2D4`): we can be
|
||||||
the interop tests all still work and still run. Turning the feature back on is
|
shown a preview, we do not originate one. A group that requires either refuses
|
||||||
re-adding `AppComponentIds.AGENT_TEXT_STREAM_QUIC_V1` to
|
us, and `CurrentProfileWelcomeTest` asserts that refusal so widening the default
|
||||||
`CurrentProfileGroupFactory.SUPPORTED_COMPONENTS`, the three roles to
|
stays a deliberate decision.
|
||||||
`MlsGroup.currentProfileLeafCapabilities()`, and the watcher to
|
|
||||||
`MarmotGroupChatView`.
|
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
|
## Not done
|
||||||
|
|
||||||
|
|||||||
+7
@@ -84,6 +84,13 @@ object CurrentProfileGroupFactory {
|
|||||||
AppComponentIds.ACCOUNT_IDENTITY_PROOF_V2,
|
AppComponentIds.ACCOUNT_IDENTITY_PROOF_V2,
|
||||||
AppComponentIds.GROUP_ENCRYPTED_MEDIA_V2,
|
AppComponentIds.GROUP_ENCRYPTED_MEDIA_V2,
|
||||||
AppComponentIds.GROUP_LIFECYCLE_V1,
|
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. */
|
/** A leaf keypair plus the account proof authorizing it. */
|
||||||
|
|||||||
+10
-17
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.marmot.appComponents.AppComponentIds
|
|||||||
import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState
|
import com.vitorpamplona.quartz.marmot.appComponents.MarmotGroupState
|
||||||
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamCrypto
|
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamCrypto
|
||||||
import com.vitorpamplona.quartz.marmot.appComponents.agentTextStream.AgentTextStreamQuicPolicyV1
|
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.mip01Groups.MarmotGroupData
|
||||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
|
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:
|
* `0xF2D1` is the agent-text-stream RECEIVE role, for the same reason:
|
||||||
* a group carrying component `0x8006` with `required_member_roles`
|
* a group carrying component `0x8006` with `required_member_roles`
|
||||||
* naming `receive` refuses a leaf that does not advertise it. We stop
|
* naming `receive` refuses a leaf that does not advertise it. The
|
||||||
* at receive — see `CurrentProfileGroupFactory.SUPPORTED_COMPONENTS`.
|
* 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 =
|
fun currentProfileLeafCapabilities(): Capabilities =
|
||||||
Capabilities(
|
Capabilities(
|
||||||
@@ -3441,21 +3448,7 @@ class MlsGroup private constructor(
|
|||||||
listOf(
|
listOf(
|
||||||
AppDataDictionary.EXTENSION_TYPE,
|
AppDataDictionary.EXTENSION_TYPE,
|
||||||
MarmotGroupData.EXTENSION_ID_INT,
|
MarmotGroupData.EXTENSION_ID_INT,
|
||||||
// The agent-stream roles (`0xF2D1` receive, `0xF2D2`
|
AgentTextStreamRoles.RECEIVE_CAPABILITY,
|
||||||
// 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.
|
|
||||||
),
|
),
|
||||||
proposals = listOf(APP_DATA_UPDATE_PROPOSAL_TYPE, SELF_REMOVE_PROPOSAL_TYPE),
|
proposals = listOf(APP_DATA_UPDATE_PROPOSAL_TYPE, SELF_REMOVE_PROPOSAL_TYPE),
|
||||||
)
|
)
|
||||||
|
|||||||
+15
-14
@@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.marmot.appComponents
|
|||||||
|
|
||||||
import com.vitorpamplona.quartz.TestResourceLoader
|
import com.vitorpamplona.quartz.TestResourceLoader
|
||||||
import com.vitorpamplona.quartz.marmot.appComponents.accountIdentityProof.AccountIdentityProofV2
|
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.MarmotGroupData
|
||||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite
|
import com.vitorpamplona.quartz.marmot.mip01Groups.MlsCiphersuite
|
||||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||||
@@ -106,26 +107,26 @@ class CurrentProfileGroupFactoryTest {
|
|||||||
assertContentEquals(ByteArray(0), kpDictionary[AppComponentIds.LAST_RESORT_KEY_PACKAGE])
|
assertContentEquals(ByteArray(0), kpDictionary[AppComponentIds.LAST_RESORT_KEY_PACKAGE])
|
||||||
|
|
||||||
// Capabilities advertise the draft extension the current profile
|
// Capabilities advertise the draft extension the current profile
|
||||||
// needs, plus the legacy 0xF2EE group-data extension and all three
|
// needs, plus the legacy `0xF2EE` group-data extension and the
|
||||||
// agent-text-stream roles — the same set MDK puts on every
|
// `0xF2D1` agent-text-stream RECEIVE role.
|
||||||
// KeyPackage it publishes.
|
|
||||||
//
|
//
|
||||||
// `0xF2EE` is deliberate and is NOT drift from the MDK reference:
|
// Both extras are there for the same reason, and it is not a
|
||||||
// a legacy group REQUIRES it, and a group refuses to add a leaf
|
// hedge: a group refuses to add a leaf that does not advertise
|
||||||
// that does not advertise what it requires, so without it a
|
// what it requires. A legacy group REQUIRES `0xF2EE`, so without
|
||||||
// current-profile KeyPackage would be un-addable to every legacy
|
// it a current-profile KeyPackage would be un-addable to every
|
||||||
// group that already exists.
|
// 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
|
// `send` and `fanout` stay absent: we can be shown a preview, we
|
||||||
// than a group requires is harmless to that group but is not free:
|
// do not originate one, and a capability is a standing promise to
|
||||||
// it is a standing claim to every peer that reads this KeyPackage,
|
// 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.
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf(
|
listOf(
|
||||||
AppDataDictionary.EXTENSION_TYPE,
|
AppDataDictionary.EXTENSION_TYPE,
|
||||||
MarmotGroupData.EXTENSION_ID_INT,
|
MarmotGroupData.EXTENSION_ID_INT,
|
||||||
|
AgentTextStreamRoles.RECEIVE_CAPABILITY,
|
||||||
),
|
),
|
||||||
kp.leafNode.capabilities.extensions,
|
kp.leafNode.capabilities.extensions,
|
||||||
)
|
)
|
||||||
|
|||||||
+52
-16
@@ -156,26 +156,61 @@ class CurrentProfileWelcomeTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Our published KeyPackage advertises NO agent-stream role, and is
|
* Our published KeyPackage satisfies the policy the reference client puts
|
||||||
* therefore refused by a group that requires one.
|
* on EVERY group it creates.
|
||||||
*
|
*
|
||||||
* That refusal is the deliberate cost of not advertising, so it is asserted
|
* `userToAgentDefault()` is not a hypothetical: `create_group` in the
|
||||||
* rather than discovered: the implementation is still here and still
|
* reference installs exactly it, requiring `receive` of every invitee, and
|
||||||
* tested, but a capability is a standing promise to every peer that reads
|
* refuses a KeyPackage that does not advertise `0xF2D1`. So this is the
|
||||||
* the KeyPackage, and we do not make one for a path nothing uses. If this
|
* assertion that decides whether an Amethyst user can be invited into a
|
||||||
* test starts failing because the default advertises a role again, that is
|
* group started by that client at all — it failed for real once, when the
|
||||||
* a decision to take on purpose, not a drift to absorb.
|
* role was dropped from the default leaf.
|
||||||
*/
|
*/
|
||||||
@Test
|
@Test
|
||||||
fun ourDefaultLeafAdvertisesNoStreamRoleAndIsRefusedByAGroupThatNeedsOne() =
|
fun ourDefaultLeafIsAdmittedByTheReferenceStreamPolicy() =
|
||||||
runBlocking<Unit> {
|
runBlocking<Unit> {
|
||||||
val group = aGroup(AgentTextStreamQuicPolicyV1.userToAgentDefault())
|
val group = aGroup(AgentTextStreamQuicPolicyV1.userToAgentDefault())
|
||||||
val invitee = CurrentProfileGroupFactory.createKeyPackage(signer(0x77))
|
val invitee = CurrentProfileGroupFactory.createKeyPackage(signer(0x77))
|
||||||
|
|
||||||
assertTrue(
|
assertTrue(
|
||||||
invitee.keyPackage.leafNode.capabilities.extensions
|
invitee.keyPackage.leafNode.capabilities.extensions
|
||||||
.none { it in AgentTextStreamRoles.ALL_CAPABILITIES },
|
.contains(AgentTextStreamRoles.RECEIVE_CAPABILITY),
|
||||||
"the default leaf must carry no agent-stream role, got ${invitee.keyPackage.leafNode.capabilities.extensions}",
|
"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<Unit> {
|
||||||
|
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())
|
group.proposeAdd(invitee.keyPackage.toTlsBytes())
|
||||||
@@ -205,11 +240,12 @@ class CurrentProfileWelcomeTest {
|
|||||||
* A current-profile KeyPackage whose leaf advertises exactly [roles] on top
|
* A current-profile KeyPackage whose leaf advertises exactly [roles] on top
|
||||||
* of the default capability set.
|
* of the default capability set.
|
||||||
*
|
*
|
||||||
* The default set no longer carries any agent-stream role, so these tests
|
* The default set carries `receive` and nothing beyond it, so a test that
|
||||||
* build the leaf they need instead of relying on it. That is the right
|
* needs `send` or `fanout` builds the leaf it needs instead of relying on
|
||||||
* shape regardless: a test that asserted the gate through OUR default was
|
* the default. That is the right shape regardless: a test that asserted the
|
||||||
* really asserting the default, and stopped testing the gate the moment the
|
* gate through OUR default was really asserting the default, and stopped
|
||||||
* default changed — which is exactly what happened.
|
* testing the gate the moment the default changed — which is exactly what
|
||||||
|
* happened, twice, in both directions.
|
||||||
*/
|
*/
|
||||||
private suspend fun keyPackageAdvertising(
|
private suspend fun keyPackageAdvertising(
|
||||||
signer: NostrSignerInternal,
|
signer: NostrSignerInternal,
|
||||||
|
|||||||
Reference in New Issue
Block a user