Skip to content

xmtp_mls: EXTERNAL_COMMIT_POLICY component + enable/revoke API with XIP-82 lifecycle invariants#3666

Open
tylerhawkes wants to merge 1 commit into
tyler/qr-invite-l5-invite-encryptionfrom
tyler/qr-invite-l6-external-commit-policy
Open

xmtp_mls: EXTERNAL_COMMIT_POLICY component + enable/revoke API with XIP-82 lifecycle invariants#3666
tylerhawkes wants to merge 1 commit into
tyler/qr-invite-l5-invite-encryptionfrom
tyler/qr-invite-l6-external-commit-policy

Conversation

@tylerhawkes

@tylerhawkes tylerhawkes commented May 21, 2026

Copy link
Copy Markdown
Contributor

Summary

The EXTERNAL_COMMIT_POLICY well-known component (0x800C) and its full lifecycle, per the revised XIP-82:

  • Typed wire shapeSymmetricKey submessage (absence is the only cleared encoding; an empty submessage is the forbidden second representable state), plus the new max_uses (concurrent per-invite cap) and refresh_pointers (member-driven blob-refresh locations).
  • enable_external_commits(settings) → ExternalInviteCoordinates — mints a fresh CSPRNG key + slot id (uniform 256-bit randomness replaces key-history tracking for no-key-revival), applies the caller's settings, and establishes the GROUP_MEMBERSHIP external-committer insert grant in the same commit when absent. Without the grant the switch would be on but every join would dead-end at validation (the atomic external-commit shape requires the joiner's membership-entry write).
  • revoke_external_commits() — leaves every per-invite field absent (key, slot id, campaign expiry, refresh pointers) while preserving the durable settings (expire_in_ns, max_uses). A revoked policy serializes byte-identical to one that never had an invite.
  • Multi-component atomic commitsAppDataUpdateIntentData.additional_updates + stage_app_data_propose_many_and_commit: N standalone AppDataUpdate proposals swept into one commit (the existing lazy-batching design makes this a small generalization). This is what makes enable atomicity real on the send side.
  • Receive-side post-state invariantvalidate_external_commit_policy_post_state runs on every commit carrying a policy write: field-coupling invariants (validate_policy_v1, shared with the setter) plus the grant coupling evaluated against the post-state registry (a COMPONENT_REGISTRY write in the same commit counts). A proposal that plants a key while the switch is off, or enables without the grant, is rejected by every member identically — both checks are pure functions of the commit's own payloads + the shared pre-commit registry, so the verdict converges.

Consumed by the L-7 validator (#3667) for the external-commit-time checks (master switch, envelope-timestamp time windows, max_uses counting).

Stack

Base: tyler/qr-invite-l5-invite-encryption (#3671) ← tyler/qr-invite-l4b-proto-rebump (#3751) ← main.

Test plan

  • 14 unit tests: policy round-trips, invariant acceptance (enabled shape, clean revoke with durable settings), rejections (missing/short key, short slot id, empty refresh pointer, each lingering per-invite field on revoke — including the empty-submessage key), grant checks (absent block / no insert policy / Deny / Allow), intent round-trips incl. additional_updates overflow handling.
  • cargo check --locked, just lint-rust clean.
  • Receive-side end-to-end coverage (enable-commit accepted with grant, rejected without; plant-key-while-off rejected) rides with the L-7 validator PR, which owns the test harness that drives commits through ValidatedCommit.

🤖 Generated with Claude Code

Note

Add EXTERNAL_COMMIT_POLICY component with enable/revoke API and XIP-82 lifecycle validation

  • Introduces ComponentId::EXTERNAL_COMMIT_POLICY (0x800C) and the ExternalCommitPolicyV1 proto type to store per-group external commit settings in app data.
  • Adds MlsGroup::enable_external_commits (high-level) and set_external_commit_policy (low-level) methods that mint a fresh symmetric key and external_group_id, persist the policy via an AppDataUpdate intent, and return ExternalInviteCoordinates for QR payloads.
  • Validates XIP-82 field-coupling invariants (key length, group-id length, service pointer URLs, expiry) both when setting the policy and during commit validation in validate_app_data_update_proposals_in_commit.
  • Converts wrap_payload_symmetric / unwrap_payload_symmetric to bon builder style and adds an optional aad parameter so AEAD encryption can bind cleartext envelope fields.
  • Adds encrypted_group_info helpers (AAD construction, error types) and ServicePointer builders (https_service_pointer, opaque_service_pointer) to invite/payload.rs.
  • Risk: commits containing an invalid EXTERNAL_COMMIT_POLICY value now fail validation and are non-retryable; existing callers of wrap_payload_symmetric must migrate to the builder API.

Macroscope summarized 5b4bf1f.

@claude

claude Bot commented May 21, 2026

Copy link
Copy Markdown

Claude finished @tylerhawkes's task —— View job


Code Review Complete

Review Date: 2026-06-09 23:54 UTC

This is a well-architected implementation of XIP-82's external commit policy with strong invariant enforcement. The code demonstrates careful attention to security and correctness. Below are observations beyond what Macroscope has already identified:


Security & Correctness

1. Missing DM conversation check ⚠️ Critical (already flagged by Macroscope)

  • enable_external_commits() and set_external_commit_policy() lack the DM conversation type guard present in all other metadata update methods (groups/mod.rs:2128, 2317, 2375, etc.)
  • Risk: Third parties could join two-person DM conversations via external commits, violating DM privacy guarantees
  • Impact: Security violation in the most private conversation type

2. Potential error swallowing in registry lookup (external_commit_policy.rs:117)

  • Already flagged by Macroscope - .ok().flatten() silently converts decode errors to None
  • Should propagate errors via ? instead, but requires a From<ComponentRegistryError> impl

3. Post-state validation correctly handles atomic commits

  • validated_commit.rs:1702-1757 properly checks post-state registry by examining in-commit writes
  • The .rev() iteration at line 1726 correctly finds the last mutation when multiple writes target GROUP_MEMBERSHIP
  • This enables the atomic enable + grant establishment

Code Quality

4. Strong cryptographic hygiene

  • ExternalInviteCoordinates::Debug impl (external_commit_policy.rs:75-84) redacts the symmetric key while logging the slot ID - excellent security practice
  • Fresh CSPRNG generation on every enable (groups/mod.rs:2233-2234) prevents key revival attacks

5. Test gap: validator behavior not actually tested (already flagged by Macroscope)

  • external_commit_policy.rs:567-576 component_metadata_without_external_block_is_treated_as_deny
  • Test only verifies .is_none() but never invokes the validator to confirm deny-by-default behavior
  • Misleading test name creates false confidence

6. Potential panic in unreachable branch (sender_intents.rs:88)

  • unreachable!() macro used for what should be an impossible state
  • Consider debug_assert!() or returning an error for defensive programming

7. Excellent documentation

  • Module-level docs clearly explain the two-layer gating model (external_commit_policy.rs:1-21)
  • Invariant documentation with clear "why" explanations (e.g., lines 108-116 on revoke semantics)
  • XIP-82 references throughout make the spec relationship transparent

Performance

8. Efficient in-batch accumulation

  • app_data/mod.rs:102-172 properly chains multiple proposals targeting the same component
  • Critical for bootstrap commits with multiple registry writes

Test Coverage

According to Codecov: 45.6% patch coverage with 81 missing lines. Key gaps:

  • enable_external_commits() / revoke_external_commits() appear untested end-to-end
  • apply_app_data_update_intent() multi-component path (sender_intents.rs:265-314) lacks coverage
  • Post-state validation happy path (validated_commit.rs:1657-1767) only 19/110 lines hit

The PR description mentions receive-side coverage will come with the L-7 validator PR, which is reasonable for a stacked PR approach.


Recommendations

Must fix before merge:

  1. Add DM conversation check to enable_external_commits() and set_external_commit_policy() (same pattern as update_commit_log_signer:2128)

Should fix:
2. Fix error handling in external_committer_permissions_for() line 117 (or document why decode errors should be treated as absent)
3. Rename or fix test at line 567 to actually test validator behavior

Nice to have:
4. Add a basic smoke test for enable_external_commits() that verifies the returned coordinates and queued intent
5. Replace unreachable!() at sender_intents.rs:88 with a defensive error return


/// group to be migrated to AppData. The component's
/// `permissions.update_policy` (super-admin-only by default) gates
/// who can flip the bits.
pub async fn set_external_commit_policy(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Critical groups/mod.rs:2015

set_external_commit_policy allows external commit policies to be set on DM conversations, bypassing the restriction that other metadata update methods enforce. This could enable third parties to join a two-person DM via external commits. Consider adding the DM check that other methods use: if self.metadata().await?.conversation_type == ConversationType::Dm { return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into()); }

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file crates/xmtp_mls/src/groups/mod.rs around line 2015:

`set_external_commit_policy` allows external commit policies to be set on DM conversations, bypassing the restriction that other metadata update methods enforce. This could enable third parties to join a two-person DM via external commits. Consider adding the DM check that other methods use: `if self.metadata().await?.conversation_type == ConversationType::Dm { return Err(MetadataPermissionsError::DmGroupMetadataForbidden.into()); }`

Evidence trail:
crates/xmtp_mls/src/groups/mod.rs lines 2015-2046 (set_external_commit_policy missing DM check), lines 1987-2004 (update_commit_log_signer with DM check at 1993-1995), lines 2051-2060 (set_allow_external_commit wrapper). git_grep for DmGroupMetadataForbidden shows the check at lines 1901, 1927, 1994, 2086, 2144, 2184, 2482 — all other metadata update paths.

@macroscopeapp

macroscopeapp Bot commented May 21, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

6 blocking correctness issues found. Diff is too large for automated approval analysis. A human reviewer should evaluate this PR.

You can customize Macroscope's approvability policy. Learn more.

@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l6-external-commit-policy branch from 60eeee5 to 9d4e150 Compare May 21, 2026 08:19
@codecov

codecov Bot commented May 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 49.52199% with 264 lines in your changes missing coverage. Please review.
✅ Project coverage is 84.25%. Comparing base (d58d4c3) to head (ccb136f).

Files with missing lines Patch % Lines
crates/xmtp_mls/src/groups/mod.rs 0.00% 80 Missing ⚠️
crates/xmtp_mls/src/groups/validated_commit.rs 12.22% 79 Missing ⚠️
...ates/xmtp_mls/src/groups/external_commit_policy.rs 74.82% 71 Missing ⚠️
...tes/xmtp_mls/src/groups/app_data/sender_intents.rs 0.00% 16 Missing ⚠️
crates/xmtp_mls/src/groups/intents.rs 44.44% 15 Missing ⚠️
...s/xmtp_mls/src/groups/app_data/component_source.rs 0.00% 1 Missing ⚠️
crates/xmtp_mls/src/groups/app_data/mod.rs 96.15% 1 Missing ⚠️
crates/xmtp_mls/src/groups/error.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@                           Coverage Diff                            @@
##           tyler/qr-invite-l5-invite-encryption    #3666      +/-   ##
========================================================================
- Coverage                                 84.54%   84.25%   -0.29%     
========================================================================
  Files                                       409      410       +1     
  Lines                                     60248    60747     +499     
========================================================================
+ Hits                                      50938    51184     +246     
- Misses                                     9310     9563     +253     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l6-external-commit-policy branch 3 times, most recently from c6765a6 to 2cdc15e Compare May 27, 2026 18:17
@tylerhawkes tylerhawkes changed the base branch from main to tyler/qr-invite-l5-invite-encryption May 27, 2026 18:18
Comment thread crates/xmtp_mls/src/groups/external_commit_policy.rs Outdated
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l6-external-commit-policy branch from 2cdc15e to 67cd34e Compare May 27, 2026 18:33
Comment on lines +117 to +336
component_id: ComponentId,
) -> Result<Option<ComponentPermissions>, ComponentSourceError> {
let registry = load_component_registry(mls_group)?;
let Some(meta) = registry.get(&component_id).ok().flatten() else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium groups/external_commit_policy.rs:117

registry.get(&component_id).ok().flatten() silently discards decode errors by converting them to None, causing the function to return Ok(None) instead of Err(_) when component metadata fails to decode. This violates the documented contract on lines 104-108 where decode failures should surface as errors. Consider using ? to propagate the error instead of ok().flatten().

-    let Some(meta) = registry.get(&component_id).ok().flatten() else {
+    let Some(meta) = registry.get(&component_id)? else {
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file crates/xmtp_mls/src/groups/external_commit_policy.rs around line 117:

`registry.get(&component_id).ok().flatten()` silently discards decode errors by converting them to `None`, causing the function to return `Ok(None)` instead of `Err(_)` when component metadata fails to decode. This violates the documented contract on lines 104-108 where decode failures should surface as errors. Consider using `?` to propagate the error instead of `ok().flatten()`.

Evidence trail:
crates/xmtp_mls/src/groups/external_commit_policy.rs lines 96-121 (REVIEWED_COMMIT) — documented contract at line 108 says `Err(_)` for registry decode failure, but line 117 uses `.ok().flatten()` which converts errors to `None`.
crates/xmtp_mls_common/src/app_data/component_registry.rs lines 103-119 (REVIEWED_COMMIT) — `ComponentRegistry::get()` returns `Result<Option<ComponentMetadata>, ComponentRegistryError>`, returning `Err(DecodeError{..})` on protobuf decode failure.
No `From<ComponentRegistryError> for ComponentSourceError` impl exists (verified via git_grep).

@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l5-invite-encryption branch 3 times, most recently from 03ad9fe to 54721d0 Compare June 6, 2026 05:07
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l5-invite-encryption branch 3 times, most recently from 004ddc2 to d58d4c3 Compare June 9, 2026 23:25
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l6-external-commit-policy branch from 67cd34e to ba504d7 Compare June 9, 2026 23:45
@tylerhawkes tylerhawkes changed the title xmtp_mls: add EXTERNAL_COMMIT_POLICY component + setter API riding on generic AppDataUpdate intent xmtp_mls: EXTERNAL_COMMIT_POLICY component + enable/revoke API with XIP-82 lifecycle invariants Jun 9, 2026
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l6-external-commit-policy branch from ba504d7 to ccb136f Compare June 9, 2026 23:52
openmls_group,
)?;

if let openmls::messages::proposals::AppDataUpdateOperation::Update(payload) =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low groups/validated_commit.rs:1619

policy_write and registry_write are only updated on AppDataUpdateOperation::Update operations, so a later Remove for the same component leaves the stale Update bytes in the tracker. validate_external_commit_policy_post_state then validates against the wrong (non-final) state, rejecting valid commits where Remove should win. Consider clearing the tracker on Remove so the last operation's effect is always reflected.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/validated_commit.rs around line 1619:

`policy_write` and `registry_write` are only updated on `AppDataUpdateOperation::Update` operations, so a later `Remove` for the same component leaves the stale `Update` bytes in the tracker. `validate_external_commit_policy_post_state` then validates against the wrong (non-final) state, rejecting valid commits where `Remove` should win. Consider clearing the tracker on `Remove` so the last operation's effect is always reflected.

// variants fail closed at decode time.

impl From<AppDataUpdateIntentData> for Vec<u8> {
fn from(intent: AppDataUpdateIntentData) -> Self {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium groups/intents.rs:1079

Vec::<u8>::from(AppDataUpdateIntentData) writes additional_updates into the intent blob, but older binaries decoding that blob silently drop unknown protobuf fields. If a queued enable_external_commits intent is processed by an older version, the coupled COMPONENT_REGISTRY/GROUP_MEMBERSHIP grant update is lost, breaking the same-commit atomicity that XIP-82 guarantees. Consider using a new intent type or a wire-format change that older versions reject rather than silently truncate.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/intents.rs around line 1079:

`Vec::<u8>::from(AppDataUpdateIntentData)` writes `additional_updates` into the intent blob, but older binaries decoding that blob silently drop unknown protobuf fields. If a queued `enable_external_commits` intent is processed by an older version, the coupled `COMPONENT_REGISTRY`/`GROUP_MEMBERSHIP` grant update is lost, breaking the same-commit atomicity that XIP-82 guarantees. Consider using a new intent type or a wire-format change that older versions reject rather than silently truncate.

Comment on lines +2274 to +2279
let current = self
.load_mls_group_with_lock(self.context.mls_storage(), |mls_group| {
external_commit_policy::load_external_commit_policy(&mls_group)
.map_err(GroupError::from)
})?
.unwrap_or_default();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium groups/mod.rs:2274

revoke_external_commits calls load_external_commit_policy(...).unwrap_or_default() and copies expire_in_ns and max_uses from the result. When an older client loads a policy written by a newer client with an unknown ExternalCommitPolicyEntry variant, load_external_commit_policy returns Ok(None), causing unwrap_or_default() to return a zeroed/default struct. The durable settings are then silently overwritten with zeros instead of being preserved, losing the original policy configuration across mixed-version clients.

-        let current = self
-            .load_mls_group_with_lock(self.context.mls_storage(), |mls_group| {
-                external_commit_policy::load_external_commit_policy(&mls_group)
-                    .map_err(GroupError::from)
-            })?
-            .unwrap_or_default();
Also found in 1 other location(s)

crates/xmtp_mls/src/groups/external_commit_policy.rs:331

external_committer_permissions_for suppresses decode failures from ComponentRegistry::get by calling .ok().flatten() on the result. If the registry contains a corrupt ComponentMetadata entry for a component, this helper returns Ok(None) and the caller treats it as an ordinary "all-Deny" absence instead of surfacing the malformed registry state. That silently changes behavior and hides data corruption from the validator.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/mod.rs around lines 2274-2279:

`revoke_external_commits` calls `load_external_commit_policy(...).unwrap_or_default()` and copies `expire_in_ns` and `max_uses` from the result. When an older client loads a policy written by a newer client with an unknown `ExternalCommitPolicyEntry` variant, `load_external_commit_policy` returns `Ok(None)`, causing `unwrap_or_default()` to return a zeroed/default struct. The durable settings are then silently overwritten with zeros instead of being preserved, losing the original policy configuration across mixed-version clients.

Also found in 1 other location(s):
- crates/xmtp_mls/src/groups/external_commit_policy.rs:331 -- `external_committer_permissions_for` suppresses decode failures from `ComponentRegistry::get` by calling `.ok().flatten()` on the result. If the registry contains a corrupt `ComponentMetadata` entry for a component, this helper returns `Ok(None)` and the caller treats it as an ordinary "all-Deny" absence instead of surfacing the malformed registry state. That silently changes behavior and hides data corruption from the validator.

Comment thread crates/xmtp_mls/src/groups/app_data/mod.rs
/// Pure function of the value; the cross-component grant check is
/// separate (see [`grant_admits_joiner_insert`]) because it needs the
/// post-state registry.
pub(crate) fn validate_policy_v1(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium groups/external_commit_policy.rs:136

validate_policy_v1 does not check grant_admits_joiner_insert even when allow_external_commit is true, so MissingMembershipGrant is never returned by this function. Callers can queue an enabled policy that will later fail validation during commit because the group lacks the GROUP_MEMBERSHIP external-committer insert grant. Consider adding the grant check to the validation function, or ensuring all callers invoke it separately before accepting the policy.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/external_commit_policy.rs around line 136:

`validate_policy_v1` does not check `grant_admits_joiner_insert` even when `allow_external_commit` is true, so `MissingMembershipGrant` is never returned by this function. Callers can queue an enabled policy that will later fail validation during commit because the group lacks the `GROUP_MEMBERSHIP` external-committer insert grant. Consider adding the grant check to the validation function, or ensuring all callers invoke it separately before accepting the policy.

/// definition neither admin nor super-admin at validation time, so any
/// stricter base policy denies it; the validator's atomic-shape checks
/// — own entry only — bound what "Allow" can do.)
pub(crate) fn grant_admits_joiner_insert(perms: Option<&ComponentPermissions>) -> bool {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low groups/external_commit_policy.rs:193

grant_admits_joiner_insert returns false for MetadataPolicy values like AnyCondition([Base(Allow), Base(AllowIfAdmin)]) even though this policy semantically allows the external committer to insert. Because the validator calls this helper before accepting an enabled policy, valid configurations will have external commits incorrectly denied.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/external_commit_policy.rs around line 193:

`grant_admits_joiner_insert` returns `false` for `MetadataPolicy` values like `AnyCondition([Base(Allow), Base(AllowIfAdmin)])` even though this policy semantically allows the external committer to insert. Because the validator calls this helper before accepting an enabled policy, valid configurations will have external commits incorrectly denied.

/// leaves every per-invite field absent). Not retryable — the caller
/// supplied a malformed policy.
#[error("external commit policy error: {0}")]
ExternalCommitPolicy(#[from] super::external_commit_policy::ExternalCommitPolicyError),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Low groups/error.rs:289

GroupError::ExternalCommitPolicy wraps ExternalCommitPolicyError without #[error_code(inherit)], and ExternalCommitPolicyError doesn't implement ErrorCode. When the Node/WASM/mobile bindings call error_code(), every variant of ExternalCommitPolicyError is flattened to the single code GroupError::ExternalCommitPolicy. SDK consumers cannot distinguish MissingSymmetricKey, InvalidExternalGroupIdLength, MissingMembershipGrant, etc. without parsing the free-form message string. Consider adding #[error_code(inherit)] to the variant (if the inner type gains ErrorCode) or implementing ErrorCode directly on ExternalCommitPolicyError.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/error.rs around line 289:

`GroupError::ExternalCommitPolicy` wraps `ExternalCommitPolicyError` without `#[error_code(inherit)]`, and `ExternalCommitPolicyError` doesn't implement `ErrorCode`. When the Node/WASM/mobile bindings call `error_code()`, every variant of `ExternalCommitPolicyError` is flattened to the single code `GroupError::ExternalCommitPolicy`. SDK consumers cannot distinguish `MissingSymmetricKey`, `InvalidExternalGroupIdLength`, `MissingMembershipGrant`, etc. without parsing the free-form message string. Consider adding `#[error_code(inherit)]` to the variant (if the inner type gains `ErrorCode`) or implementing `ErrorCode` directly on `ExternalCommitPolicyError`.

tylerhawkes added a commit that referenced this pull request Jun 11, 2026
…ves) + adapt invite::payload

Regenerates xmtp_proto from xmtp/proto#336 (typed SymmetricKey /
GroupStateHash / ServicePointer newtypes, ExternalCommitPolicyV1
max_uses + refresh_pointers, EncryptedGroupInfoBlobV1 AAD +
effective-expiry semantics, GroupMembershipEntry.V1
admitted_via_external_group_id).

Consuming changes kept to what main already contains:

- invite::payload rewritten for the typed shape: symmetric_key is a
  SymmetricKey submessage (MissingSymmetricKey + material-length
  checks), service_pointer is an optional ServicePointer (absent =
  application-resolved; present-but-empty oneof fails closed;
  https_url parsed and scheme-checked via url). New
  https_service_pointer / opaque_service_pointer /
  validate_service_pointer helpers.

- GroupMembershipEntry.V1 construction sites gain the new
  admitted_via_external_group_id field, empty everywhere: the
  migrator's synthesized entries are all Welcome/legacy members
  (absent is permanently correct), and the membership-update rewrite
  path documents that tag preservation MUST land together with the
  validator's write-once enforcement in the external-commit stack —
  nothing on main can set the tag yet, so empty is correct today.

The rest of the QR-invite stack (#3671 -> #3666 -> #3667 -> #3668 ->
#3673 -> #3674) rebases onto this and picks up the new fields layer
by layer.
tylerhawkes added a commit that referenced this pull request Jun 11, 2026
…ves) + adapt invite::payload

Regenerates xmtp_proto from xmtp/proto#336 (typed SymmetricKey /
GroupStateHash / ServicePointer newtypes, ExternalCommitPolicyV1
max_uses + refresh_pointers, EncryptedGroupInfoBlobV1 AAD +
effective-expiry semantics, GroupMembershipEntry.V1
admitted_via_external_group_id).

Consuming changes kept to what main already contains:

- invite::payload rewritten for the typed shape: symmetric_key is a
  SymmetricKey submessage (MissingSymmetricKey + material-length
  checks), service_pointer is an optional ServicePointer (absent =
  application-resolved; present-but-empty oneof fails closed;
  https_url parsed and scheme-checked via url). New
  https_service_pointer / opaque_service_pointer /
  validate_service_pointer helpers.

- GroupMembershipEntry.V1 construction sites gain the new
  admitted_via_external_group_id field, empty everywhere: the
  migrator's synthesized entries are all Welcome/legacy members
  (absent is permanently correct), and the membership-update rewrite
  path documents that tag preservation MUST land together with the
  validator's write-once enforcement in the external-commit stack —
  nothing on main can set the tag yet, so empty is correct today.

The rest of the QR-invite stack (#3671 -> #3666 -> #3667 -> #3668 ->
#3673 -> #3674) rebases onto this and picks up the new fields layer
by layer.
…th XIP-82 lifecycle invariants

enable_external_commits mints fresh CSPRNG coordinates and applies the
caller settings in a single-component AppDataUpdate commit — under the
revised XIP-82, allow_external_commit itself authorizes the joiner
self-entry insert, so no companion registry grant exists.
revoke_external_commits preserves durable settings (expire_in_ns,
max_uses) and leaves every per-invite field absent. Receive side:
validate_external_commit_policy_post_state enforces the field-coupling
shape (validate_policy_v1) on every commit carrying a policy write.
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l6-external-commit-policy branch from ccb136f to 5b4bf1f Compare June 11, 2026 05:08
@tylerhawkes tylerhawkes force-pushed the tyler/qr-invite-l5-invite-encryption branch from d58d4c3 to ea93854 Compare June 11, 2026 05:09
@@ -2136,6 +2138,144 @@ where
Ok(())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High groups/mod.rs:2138

queue_external_commit_policy writes the EXTERNAL_COMMIT_POLICY component via AppDataUpdate, but validate_one_app_data_update requires every component to have a COMPONENT_REGISTRY entry seeded during migration. The migration code never creates this registry entry for EXTERNAL_COMMIT_POLICY, so the first call to set_external_commit_policy, enable_external_commits, or revoke_external_commits on a migrated group fails with a permissions error. Consider seeding the EXTERNAL_COMMIT_POLICY registry entry during the bootstrap migration in enable_proposals_inner.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/mod.rs around line 2138:

`queue_external_commit_policy` writes the `EXTERNAL_COMMIT_POLICY` component via `AppDataUpdate`, but `validate_one_app_data_update` requires every component to have a `COMPONENT_REGISTRY` entry seeded during migration. The migration code never creates this registry entry for `EXTERNAL_COMMIT_POLICY`, so the first call to `set_external_commit_policy`, `enable_external_commits`, or `revoke_external_commits` on a migrated group fails with a permissions error. Consider seeding the `EXTERNAL_COMMIT_POLICY` registry entry during the bootstrap migration in `enable_proposals_inner`.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant