feat(xmtp_mls): report app data changes and guard updates against clobbering - #4011
Conversation
|
Claude finished @tylerhawkes's task —— View job Code Review — August 19, 2026, 20:05 UTCReviewed PR #4011: app-data change callbacks and CAS guards SummaryStrong implementation overall. The change callback mechanism is well-designed with proper lock ordering, and the CAS guard correctly prevents epoch-race clobbering. Test coverage is comprehensive and documents the key behaviors. Macroscope has already identified the major correctness issues; my review focuses on additional concerns not yet covered. Key Findings1. Unclear semantics for initially-absent app_data (Medium severity) The CAS guard cannot distinguish "expect the slot to be absent" from "disable the guard." Both map to // groups/intents.rs:234
pub fn new_update_app_data(app_data: String, expected_app_data: Option<String>) -> Self {
match expected_app_data {
Some(expected) => Self::new_guarded(...),
None => Self::new(...), // unguarded last-writer-wins
}
}This creates a gap: a host reacting to Recommendation: Use a nested option or enum to represent three states: Macroscope also flagged this (groups/mod.rs:2342, groups/intents.rs:238), but it's worth emphasizing: this is a semantic hole in the CAS contract that undermines the feature's core value prop. 2. Silent read failures on metadata fields (Low severity)
// groups/mls_sync.rs:3313-3315
if let Some(committed) = Self::read_metadata_field(openmls_group, &metadata_intent.field_name)
&& committed.as_deref() != Some(expected.as_str())
{
// abandon
}If Recommendation: Distinguish read failure from absent value. Return 3. Test coverage: welcome path is silent (Low severity — already documented) The PR description notes that joining a group via welcome never fires the callback, since welcomes bypass Recommendation: File a follow-up issue to add a join-time callback or explicitly fire 4. Callback dispatch races with commit log observer (Low severity)
Observation: This is likely acceptable — the alternative (concurrent dispatch) would let hosts publish merges based on stale state, which is worse. Just noting it for visibility: a slow callback is a DoS vector against your own sync latency. 5. Missing error context on superseded intent (Low severity) When a guard fails, Recommendation: Add 6. Test gap: concurrent callbacks on multiple groups (Low severity) All tests exercise a single group. A multi-group scenario where callbacks on groups A and B both try to publish back into their respective groups isn't covered. The per-group mutex should make this safe, but a test pinning it would be valuable. Recommendation: Add a test that syncs two groups concurrently, both with callbacks that publish back. Assert that neither deadlocks and both complete successfully. 7. Positive: excellent test documentation The tests in Minor Observations
Non-Issues (for the record)
Bottom LineThe core implementation is sound. The callback dispatch correctly avoids the deadlock, the guard prevents clobbering on republish, and the test suite documents the key contracts. The main gap is the initially-absent CAS case (finding #1), which should be addressed before stabilizing the API. The other findings are polish or follow-ups. |
ApprovabilityVerdict: Not approved Macroscope's review found this PR not approvable — This PR introduces a new callback system for group state changes and compare-and-swap guards against concurrent overwrites. The scope includes new async traits, callback dispatch infrastructure, and modifications to core sync/publish logic. Multiple High and Medium severity findings remain unresolved regarding potential deadlocks and edge cases in the guard mechanism. Not approved because:
Adjust the Minimum Blocking Severity for this repo — including turning it Off — in Settings. You can add or adjust custom eligibility rules. Learn more. |
e77d823 to
9aceae0
Compare
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## tyler/appdata-cb-db #4011 +/- ##
======================================================
Coverage ? 85.91%
======================================================
Files ? 418
Lines ? 68328
Branches ? 0
======================================================
Hits ? 58705
Misses ? 9623
Partials ? 0 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
930e89e to
8648bef
Compare
| "Task {} is a KpLiveness task, which this version does not handle. Deleting.", | ||
| task.id | ||
| ); | ||
| context.db().delete_task(task.id)?; |
There was a problem hiding this comment.
🟡 Medium worker/tasks.rs:454
An older client deletes a KpLiveness task written by a newer client, permanently discarding the newer client's pending liveness work. Since run_and_reschedule_task also deletes rows for TaskOutcome::Done, this arm needs an outcome that preserves unknown task kinds rather than treating them as completed.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/worker/tasks.rs around line 454:
An older client deletes a `KpLiveness` task written by a newer client, permanently discarding the newer client's pending liveness work. Since `run_and_reschedule_task` also deletes rows for `TaskOutcome::Done`, this arm needs an outcome that preserves unknown task kinds rather than treating them as completed.
8648bef to
be58d1c
Compare
| // Fail the already-stale case before touching the network. The | ||
| // authoritative check runs again at publish time, which is what | ||
| // catches a change that lands between here and the commit. | ||
| if let Some(expected) = &expected_app_data { |
There was a problem hiding this comment.
🟠 High groups/mod.rs:2342
update_app_data cannot safely CAS an initially absent slot: expected_app_data = None disables the guard, while any Some(...) expectation calls self.app_data()? and turns an absent component into MissingExtension. Consequently, a host reacting to AppDataChange { old_value: None, .. } must issue an unguarded last-writer-wins update, so a concurrent first write can be silently overwritten. Use a representation that distinguishes “no guard” from “expected absent” (for example, a nested option or enum) and compare the optional slot value directly.
Also found in 1 other location(s)
crates/xmtp_mls/src/groups/intents.rs:238
new_update_app_datausesNoneboth to mean “the caller expects the slot to be absent” and “disable the guard,” so there is no way to CAS an initially unsetapp_dataslot. This state is explicitly reachable (AppDataChange::old_valueisNonewhen no value is set). A host reacting to that callback cannot protect its merged write: passingNonecreates an unguarded last-writer-wins intent, allowing a concurrent first write to be silently overwritten. The guard needs a representation that distinguishes an expected absent value from no guard (for example, a nested option or enum).
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/mod.rs around line 2342:
`update_app_data` cannot safely CAS an initially absent slot: `expected_app_data = None` disables the guard, while any `Some(...)` expectation calls `self.app_data()?` and turns an absent component into `MissingExtension`. Consequently, a host reacting to `AppDataChange { old_value: None, .. }` must issue an unguarded last-writer-wins update, so a concurrent first write can be silently overwritten. Use a representation that distinguishes “no guard” from “expected absent” (for example, a nested option or enum) and compare the optional slot value directly.
Also found in 1 other location(s):
- crates/xmtp_mls/src/groups/intents.rs:238 -- `new_update_app_data` uses `None` both to mean “the caller expects the slot to be absent” and “disable the guard,” so there is no way to CAS an initially unset `app_data` slot. This state is explicitly reachable (`AppDataChange::old_value` is `None` when no value is set). A host reacting to that callback cannot protect its merged write: passing `None` creates an unguarded last-writer-wins intent, allowing a concurrent first write to be silently overwritten. The guard needs a representation that distinguishes an expected absent value from no guard (for example, a nested option or enum).
be58d1c to
1b61a68
Compare
1b61a68 to
c625372
Compare
| // that never happened, and a host that trusts it would write | ||
| // the slot back from stale state. | ||
| if watch_app_data | ||
| && let Ok(outcome) = result.as_mut() |
There was a problem hiding this comment.
🟡 Medium groups/mls_sync.rs:2305
A durable app-data change is permanently omitted from the registered callback when post_process_message fails after the transaction commits. Because line 2305 only attaches app_data_change for Ok(outcome), the cursor is already advanced and a retry cannot reconstruct or dispatch the change; preserve the processed outcome (or its diff) when post-processing fails.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/mls_sync.rs around line 2305:
A durable app-data change is permanently omitted from the registered callback when `post_process_message` fails after the transaction commits. Because line 2305 only attaches `app_data_change` for `Ok(outcome)`, the cursor is already advanced and a retry cannot reconstruct or dispatch the change; preserve the processed outcome (or its diff) when post-processing fails.
| // leave the intent alone and let the normal error path | ||
| // surface whatever is actually wrong. The outer `Option` | ||
| // separates "unreadable" from "readable but unset". | ||
| if let Some(committed) = |
There was a problem hiding this comment.
🟠 High groups/mls_sync.rs:3313
A guarded metadata update is published when read_metadata_field fails and returns None, so the compare-and-swap check is bypassed and the update can overwrite a committed value without verifying expected_field_value. This is especially reachable for non-app_data fields on migrated groups because the reader decodes the whole mutable-metadata composite; propagate the read error (or use a target-specific fallible reader) instead of treating an unreadable field as absent.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @crates/xmtp_mls/src/groups/mls_sync.rs around line 3313:
A guarded metadata update is published when `read_metadata_field` fails and returns `None`, so the compare-and-swap check is bypassed and the update can overwrite a committed value without verifying `expected_field_value`. This is especially reachable for non-`app_data` fields on migrated groups because the reader decodes the whole mutable-metadata composite; propagate the read error (or use a target-specific fallible reader) instead of treating an unreadable field as absent.
Stack
Merge bottom-up — each PR is based on its parent below.
Android is the priority path: #4019 → #4011 → #4012 → #4016.
IntentState::Superseded(base:main)Already merged: #4018 (proto regen). Related follow-up, independent of this stack: #4020.
Reporting changes
ClientBuilder::unstable_change_callbacks→XmtpMlsLocalContext. Registration is construction-time by necessity: remote changes surface from the stream and sync paths, where no SDK method is on the stack to carry a parameter. A per-call hook would only cover changes you caused yourself.UnstableChangeCallbackshas one field today; callbacks for the other mutable fields land as additive fields later.MlsGroup::process_messagesnapshots the group's mutable metadata before and after processing, inside the lock closure, and diffs theapp_dataslot — both reads in-memory off the already-loaded group, gated on a callback being registered. That is deliberately not an out-param threaded through the intent state machine: it reports the net change, covers the own-intent and external paths identically, and generalizes to the other fields without touching commit processing again.Dispatch happens after
load_mls_group_with_lock_asyncreturns — lock released, transaction committed — so a callback may publish its merged result on the same group. Dispatching from insidesave_transcript_messagewould hold a write transaction across a hop into Swift/Kotlin/JS and deadlock the moment the host calledupdate_app_data.DeferredEventslooks like the natural hook and is not: itssend_allruns inside the lock.Both reads must succeed before a change is reported. Comparing a good
beforeagainst a failedafterwould report a clear that never happened, and a host trusting it would write the slot back from stale state.Guarding updates
update_app_datatakes an optionalexpected_app_data.UpdateMetadataIntentDatafreezes an absolute value at queue time, and an intent that loses an epoch race is rebuilt from that same frozen payload and republished — so without a guard a concurrent change is observed and then silently overwritten.test_pending_local_intent_clobbers_a_remote_changepins that behavior;test_guarded_update_is_abandoned_instead_of_clobberingshows the guard preventing it.The authoritative check runs in
get_publish_intent_dataon every publish attempt, including the republish after an epoch loss. A mismatch marks the intentSupersededand returnsOk(None)— terminal without burning publish attempts or aborting the publish loop for other intents on the group. A synchronous pre-flight inupdate_app_datafails the already-stale case without a network round trip.Passing
Nonekeeps last-writer-wins, so nothing existing changes behavior.Two things worth a reviewer's attention:
Ok(None)arm ofpublish_intentsno longer blindly marks an intentProcessed. It would otherwise overwrite theSupersededstate set moments earlier and report a dropped write to the caller as success — a silent no-op, worse than the clobber it replaces. It now only advances an intent still inToPublish. The fullxmtp_mlssuite (724 tests) passes with that change.Known trade-off
The change payload carries no actor identity, so the callback fires for local commits as well as remote ones and merges must be idempotent. Documented on the trait and pinned by a test.
Not covered
The welcome path (
groups/welcomes/xmtp_welcome.rs) fires nothing — joining never goes throughprocess_message, so first sight ofapp_dataon join is silent. Worth a follow-up for clients that reconcile on join.🤖 Generated with Claude Code
https://claude.ai/code/session_01AdY7WKkNbJmdzpmUWvW1my
Note
Add guarded app_data updates and change callbacks to MLS group sync
UnstableChangeCallbacksandAppDataChangeCallbackinchange_callbacks.rs, allowing callers to register async callbacks that fire whenapp_datachanges during sync or message processing.expected_field_valuecompare-and-swap guard toUpdateMetadataIntentData; if the committed value differs at publish time, the intent is markedSupersededinstead of overwriting concurrent changes.update_app_datanow accepts anOption<String>guard parameter; stale guards are rejected pre-queue or at publish time and return a newGroupError::AppDataSupersededvariant.update_app_datasignature change is breaking for all call sites (bindings and apps updated to passNone).Macroscope summarized c625372.