feat: add self-update tracking fields to Group struct#184
feat: add self-update tracking fields to Group struct#184erskingardner merged 8 commits intomarmot-protocol:masterfrom
Conversation
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughReplaces per-field self-update flags with a single public Changes
Sequence Diagram(s)mermaid Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Suggested labels
Suggested reviewers
🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/mdk-storage-traits/src/groups/types.rs (1)
80-120:⚠️ Potential issue | 🔴 CriticalRedact sensitive identifiers in
Group’sDebugoutput.
GroupderivesDebugwhile carryingmls_group_id,nostr_group_id, and secret material. This violates the security rule against exposing sensitive identifiers in Debug implementations.🐛 Proposed fix (custom Debug with redaction)
-#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct Group { @@ } + +impl fmt::Debug for Group { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Group") + .field("mls_group_id", &"[REDACTED]") + .field("nostr_group_id", &"[REDACTED]") + .field("name", &self.name) + .field("description", &self.description) + .field("image_hash", &self.image_hash) + .field("image_key", &"[REDACTED]") + .field("image_nonce", &"[REDACTED]") + .field("admin_pubkeys", &self.admin_pubkeys) + .field("last_message_id", &self.last_message_id) + .field("last_message_at", &self.last_message_at) + .field("last_message_processed_at", &self.last_message_processed_at) + .field("epoch", &self.epoch) + .field("state", &self.state) + .field("needs_self_update", &self.needs_self_update) + .field("last_self_update_at", &self.last_self_update_at) + .finish() + } +}As per coding guidelines: Never log, expose, or include in Debug implementations: mls_group_id, nostr_group_id, encryption keys, exporter secrets.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/mdk-storage-traits/src/groups/types.rs` around lines 80 - 120, The struct Group currently derives Debug and exposes sensitive identifiers (mls_group_id, nostr_group_id) and secret material (image_key, image_nonce) in its Debug output; remove Debug from the derive list for Group and add a custom impl fmt::Debug for Group that prints non-sensitive fields normally but replaces mls_group_id, nostr_group_id, image_key, image_nonce (and any other secret/exporter fields) with a redacted placeholder like "<redacted>" (keep other derives like Serialize/Deserialize intact); implement the Debug impl for the Group type and reference the exact field names (mls_group_id, nostr_group_id, image_key, image_nonce, image_hash, etc.) to ensure only safe fields are rendered.
🧹 Nitpick comments (2)
crates/mdk-sqlite-storage/migrations/V004__add_self_update_tracking.sql (1)
5-6: Consider an index forgroups_needing_self_updatequeries.
groups_needing_self_update()will scan the entiregroupstable and filter onneeds_self_updateandlast_self_update_at. For current user-scale this is fine, but a partial index can make the query constant-time if the groups table ever grows (e.g. shared-server or multi-account usage):CREATE INDEX IF NOT EXISTS idx_groups_self_update ON groups (needs_self_update, last_self_update_at) WHERE state = 'Active';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/mdk-sqlite-storage/migrations/V004__add_self_update_tracking.sql` around lines 5 - 6, Add a partial index to the migration so queries from groups_needing_self_update() are fast: after adding the columns needs_self_update and last_self_update_at in V004__add_self_update_tracking.sql, create an index named idx_groups_self_update on (needs_self_update, last_self_update_at) with a WHERE clause filtering state = 'Active' (use CREATE INDEX IF NOT EXISTS idx_groups_self_update ... WHERE state = 'Active'); this keeps scanning constant-time as the groups table grows and ties directly to the new columns and the groups_needing_self_update() lookup.crates/mdk-storage-traits/src/groups/types.rs (1)
111-115: Consider#[serde(default)]forneeds_self_update.This preserves backward-compatible deserialization of older serialized
Grouppayloads that won’t have the new boolean field.♻️ Suggested tweak
- pub needs_self_update: bool, + #[serde(default)] + pub needs_self_update: bool,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/mdk-storage-traits/src/groups/types.rs` around lines 111 - 115, The deserialization will fail for older Group payloads missing the new boolean field; add serde's default behavior to the field by annotating the needs_self_update field with #[serde(default)] (i.e., change the field declaration in the Group type to #[serde(default)] pub needs_self_update: bool) so missing values deserialize to false and preserve backward compatibility.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/mdk-sqlite-storage/src/db.rs`:
- Around line 170-174: The two fields currently use unwrap_or and silently
swallow errors; change the row.get calls for needs_self_update and
last_self_update_at to propagate errors with the ? operator (e.g., let
needs_self_update: bool = row.get::<_, i64>("needs_self_update")? != 0 and let
last_self_update_at: Option<Timestamp> = row.get::<_,
Option<u64>>("last_self_update_at")?.map(Timestamp::from_secs)), matching the
other fields in this function so type errors aren't hidden; if you intentionally
want a fallback like the special-case processed_at, add an explicit comment
explaining the backward-compatibility rationale instead of using unwrap_or.
In `@crates/mdk-storage-traits/tests/shared/group_tests.rs`:
- Line 741: Move the inline import "use
mdk_storage_traits::groups::types::GroupState;" out of the function body and
place it at the top of the module scope (with the other use statements) so all
imports are at the module level; locate the test containing the local use of
GroupState and remove that line from the function body, adding the same use
declaration alongside the other imports in that file's module preamble.
---
Outside diff comments:
In `@crates/mdk-storage-traits/src/groups/types.rs`:
- Around line 80-120: The struct Group currently derives Debug and exposes
sensitive identifiers (mls_group_id, nostr_group_id) and secret material
(image_key, image_nonce) in its Debug output; remove Debug from the derive list
for Group and add a custom impl fmt::Debug for Group that prints non-sensitive
fields normally but replaces mls_group_id, nostr_group_id, image_key,
image_nonce (and any other secret/exporter fields) with a redacted placeholder
like "<redacted>" (keep other derives like Serialize/Deserialize intact);
implement the Debug impl for the Group type and reference the exact field names
(mls_group_id, nostr_group_id, image_key, image_nonce, image_hash, etc.) to
ensure only safe fields are rendered.
---
Nitpick comments:
In `@crates/mdk-sqlite-storage/migrations/V004__add_self_update_tracking.sql`:
- Around line 5-6: Add a partial index to the migration so queries from
groups_needing_self_update() are fast: after adding the columns
needs_self_update and last_self_update_at in V004__add_self_update_tracking.sql,
create an index named idx_groups_self_update on (needs_self_update,
last_self_update_at) with a WHERE clause filtering state = 'Active' (use CREATE
INDEX IF NOT EXISTS idx_groups_self_update ... WHERE state = 'Active'); this
keeps scanning constant-time as the groups table grows and ties directly to the
new columns and the groups_needing_self_update() lookup.
In `@crates/mdk-storage-traits/src/groups/types.rs`:
- Around line 111-115: The deserialization will fail for older Group payloads
missing the new boolean field; add serde's default behavior to the field by
annotating the needs_self_update field with #[serde(default)] (i.e., change the
field declaration in the Group type to #[serde(default)] pub needs_self_update:
bool) so missing values deserialize to false and preserve backward
compatibility.
alltheseas
left a comment
There was a problem hiding this comment.
Thanks for the review. Addressed the inline comments in 717458a:
- db.rs
unwrap_or→ now uses?consistently (test helper schema updated too) - group_tests.rs inline import → moved to module scope
#[serde(default)]→ added to both new fields for backward-compat deserialization
For the items I'm leaving as-is:
- Group Debug redaction: Pre-existing
derive(Debug)on the struct, not introduced by this PR.image_key/image_nonceare already wrapped inSecret<>which handles redaction. Worth a follow-up but out of scope here. - SQL partial index:
groups_needing_self_update()is a default trait method that callsall_groups()and filters in Rust — there's no SQL query to index for. If/when someone adds a SQL-native override, the index would make sense then.
alltheseas
left a comment
There was a problem hiding this comment.
Thanks for the review. Addressed the inline comments in 717458a:
- db.rs unwrap_or — now uses
?consistently (test helper schema updated too) - group_tests.rs inline import — moved to module scope
- serde(default) — added to both new fields for backward-compat deserialization
For the items I'm leaving as-is:
- Group Debug redaction: Pre-existing
derive(Debug)on the struct, not introduced by this PR.image_key/image_nonceare already wrapped inSecret<>which handles redaction. Worth a follow-up but out of scope here. - SQL partial index:
groups_needing_self_update()is a default trait method that callsall_groups()and filters in Rust — there's no SQL query to index for. If/when someone adds a SQL-native override, the index would make sense then.
717458a to
1abd9d4
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
crates/mdk-storage-traits/src/groups/types.rs (1)
389-421: Consider asserting the new fields in the serialization test.The struct literal now includes
needs_self_update: falseandlast_self_update_at: None, buttest_group_serializationnever asserts their serialized values. Adding assertions would confirm both the field names and the#[serde(default)]round-trip behavior.🔧 Suggested addition
assert_eq!(serialized["state"], json!("active")); + assert_eq!(serialized["needs_self_update"], json!(false)); + assert_eq!(serialized["last_self_update_at"], json!(null));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/mdk-storage-traits/src/groups/types.rs` around lines 389 - 421, Add assertions in the test_group_serialization test to verify the serialized values for the newly added fields needs_self_update and last_self_update_at: assert that serialized["needs_self_update"] equals json!(false) and serialized["last_self_update_at"] equals json!(null) (or the expected default representation), so the Group struct's serde default/round-trip behavior for needs_self_update and last_self_update_at is covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@crates/mdk-storage-traits/src/groups/types.rs`:
- Around line 389-421: Add assertions in the test_group_serialization test to
verify the serialized values for the newly added fields needs_self_update and
last_self_update_at: assert that serialized["needs_self_update"] equals
json!(false) and serialized["last_self_update_at"] equals json!(null) (or the
expected default representation), so the Group struct's serde default/round-trip
behavior for needs_self_update and last_self_update_at is covered.
|
@alltheseas you will need to add commit signatures before we can accept any of your PRs on our repos. |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
crates/mdk-uniffi/src/lib.rs (1)
1182-1209: Consider a less stringly-typedself_update_statefor FFI.
Mixing sentinel strings with raw timestamps can be easy to mis-parse across languages. Prefixing the timestamp (or exposing a UniFFI enum) would reduce ambiguity for clients.♻️ Low-impact prefix to make the value self-describing
- /// - `"<unix_timestamp>"`: Last self-update merged at this time (MIP-00). + /// - `"completed_at:<unix_timestamp>"`: Last self-update merged at this time (MIP-00). - group_types::SelfUpdateState::CompletedAt(ts) => ts.as_secs().to_string(), + group_types::SelfUpdateState::CompletedAt(ts) => format!("completed_at:{}", ts.as_secs()),🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/mdk-uniffi/src/lib.rs` around lines 1182 - 1209, The current conversion for Group::from maps group_types::SelfUpdateState into a string mixing sentinel values and raw timestamps via the self_update_state field, which is error-prone for FFI clients; change the mapping in the From<group_types::Group> implementation so self_update_state becomes self-describing (e.g., prefix the timestamp like "completed_at:<unix_ts>" or switch the exposed type to a UniFFI enum) by updating the match over group_types::SelfUpdateState (variants NotRequired, Required, CompletedAt) to emit the chosen prefixed string form or adjust the Group type to an enum variant instead so callers can reliably parse the state across languages.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/mdk-storage-traits/CHANGELOG.md`:
- Around line 28-37: The CHANGELOG has a duplicate "### Breaking changes" header
and misplaced entries from PR `#184`; consolidate by removing the second "###
Breaking changes" block and moving the PR `#184` bullet about SelfUpdateState (the
change replacing Group's needs_self_update/last_self_update_at with
self_update_state: SelfUpdateState and note about updating Group constructors)
into the existing "### Breaking changes" section, and move the
"**`groups_needing_self_update()` default method**" bullet under the existing
"### Added" section; keep the wording intact and ensure the headings follow the
Keep a Changelog order (Unreleased, Breaking changes, Changed, Added, Fixed,
Removed, Deprecated).
In `@crates/mdk-uniffi/CHANGELOG.md`:
- Around line 28-37: Consolidate the duplicate "### Breaking changes" headers by
moving the bullet describing the Group self-update field change (from PR `#184`)
into the existing "### Breaking changes" section and remove the extra "###
Breaking changes" header; also move the "**`groups_needing_self_update()`
method**" bullet from the stray "### Added" into the existing "### Added"
section so the changelog follows the Keep a Changelog structure and removes the
MD024 duplicate-heading warning.
---
Nitpick comments:
In `@crates/mdk-uniffi/src/lib.rs`:
- Around line 1182-1209: The current conversion for Group::from maps
group_types::SelfUpdateState into a string mixing sentinel values and raw
timestamps via the self_update_state field, which is error-prone for FFI
clients; change the mapping in the From<group_types::Group> implementation so
self_update_state becomes self-describing (e.g., prefix the timestamp like
"completed_at:<unix_ts>" or switch the exposed type to a UniFFI enum) by
updating the match over group_types::SelfUpdateState (variants NotRequired,
Required, CompletedAt) to emit the chosen prefixed string form or adjust the
Group type to an enum variant instead so callers can reliably parse the state
across languages.
- Remove duplicate '### Breaking changes' and '### Added' headers in mdk-storage-traits and mdk-uniffi changelogs, merging PR marmot-protocol#184 entries into the existing sections. - Prefix the CompletedAt timestamp in UniFFI with 'completed_at:' so FFI clients can reliably distinguish state variants by string prefix rather than parsing an ambiguous bare integer.
a3ca599 to
b449ba0
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
crates/mdk-storage-traits/tests/cross_storage.rs (1)
113-118:⚠️ Potential issue | 🟡 MinorMove in-function imports to module scope.
The
useinsidecreate_test_group_for_cross_storageviolates the repo rule for import placement. Move it to the top of the module (ormod shared) and reuse it.✅ Suggested fix
use mdk_storage_traits::GroupId; use mdk_storage_traits::groups::GroupStorage; +use mdk_storage_traits::groups::types::{Group, GroupState, SelfUpdateState}; use nostr::RelayUrl; @@ fn create_test_group_for_cross_storage( mls_group_id: &GroupId, nostr_group_id: [u8; 32], -) -> mdk_storage_traits::groups::types::Group { - use mdk_storage_traits::groups::types::{Group, GroupState, SelfUpdateState}; +) -> Group {As per coding guidelines, "All
usestatements must be placed at the TOP of their containing scope, never inside functions, methods, blocks, or individual test functions".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/mdk-storage-traits/tests/cross_storage.rs` around lines 113 - 118, Move the in-function imports out of create_test_group_for_cross_storage and into the module scope (or the existing mod shared) so they live at the top of the file; specifically, remove the `use` line inside the function and add `use mdk_storage_traits::groups::types::{Group, GroupState, SelfUpdateState};` at the top of the module so create_test_group_for_cross_storage can reference Group, GroupState, and SelfUpdateState directly.
🧹 Nitpick comments (7)
crates/mdk-sqlite-storage/migrations/V004__add_self_update_tracking.sql (2)
6-6: Consider adding an index ifgroups_needing_self_updateis called frequently.The new
groups_needing_self_update(threshold_secs)query will filter onlast_self_update_at. While groups tables tend to be small enough for a full scan today, an index on this column would keep the query fast as the table grows.CREATE INDEX IF NOT EXISTS idx_groups_last_self_update_at ON groups (last_self_update_at);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/mdk-sqlite-storage/migrations/V004__add_self_update_tracking.sql` at line 6, Add a non-blocking index for the new column to keep queries like groups_needing_self_update(threshold_secs) fast as the table grows: after the ALTER TABLE statement that adds last_self_update_at, create an index named idx_groups_last_self_update_at on the groups(last_self_update_at) column using CREATE INDEX IF NOT EXISTS so migration is idempotent and safe for production.
6-6: Three states packed into one column via magic values — consider two dedicated columns.The current encoding (
NULL/0/>0) is compact and documented, but conflating "no obligation" withNULLand "obligation pending" with the sentinel0means every reader must remember the magic-value contract. A future-proof alternative is two explicit columns:-- epoch sentinel removed; intent is unambiguous at the schema level ALTER TABLE groups ADD COLUMN needs_self_update INTEGER NOT NULL DEFAULT 0; ALTER TABLE groups ADD COLUMN last_self_update_at INTEGER;
needs_self_update = 1(flag set byaccept_welcome) andlast_self_update_at IS NULL(no rotation yet) would map one-to-one to the enum variants without any sentinel. Both columns default naturally for existing rows (0/NULL).This is a purely optional design preference; the current single-column approach is correct and safe given that a real
last_self_update_at = 0(Unix epoch) is implausible in production.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/mdk-sqlite-storage/migrations/V004__add_self_update_tracking.sql` at line 6, The migration adds a single column last_self_update_at that encodes three states via NULL/0/>0; instead, add an explicit boolean-like flag to make intent unambiguous: modify the migration that alters the groups table (ALTER TABLE groups ADD COLUMN last_self_update_at INTEGER;) to also add a needs_self_update INTEGER NOT NULL DEFAULT 0 column and keep last_self_update_at as nullable; ensure application code that sets/reads last_self_update_at and the new needs_self_update flag (e.g., the accept_welcome flow and any readers expecting the magic 0 sentinel) are updated to set needs_self_update = 1 when an update is pending and to use last_self_update_at IS NULL to mean "never updated."crates/mdk-storage-traits/src/groups/types.rs (1)
35-60: Add a unit test for the customSelfUpdateStateserde roundtrip.The sentinel value
0forRequiredis a non-obvious encoding. The existingtest_group_serializationtest doesn't assert onself_update_state, and there's no test that exercisesRequired ↔ Some(0),CompletedAt(ts) ↔ Some(ts), andNotRequired ↔ None. A wrong impl here would silently corrupt stored state.✅ Suggested test
#[test] fn test_self_update_state_serde_roundtrip() { // NotRequired serializes as null and deserializes back let not_req = SelfUpdateState::NotRequired; let json = serde_json::to_value(¬_req).unwrap(); assert_eq!(json, serde_json::Value::Null); let back: SelfUpdateState = serde_json::from_value(json).unwrap(); assert_eq!(back, SelfUpdateState::NotRequired); // Required serializes as 0 and deserializes back let req = SelfUpdateState::Required; let json = serde_json::to_value(&req).unwrap(); assert_eq!(json, serde_json::json!(0u64)); let back: SelfUpdateState = serde_json::from_value(json).unwrap(); assert_eq!(back, SelfUpdateState::Required); // CompletedAt serializes as the timestamp seconds and deserializes back let ts = Timestamp::from_secs(1_700_000_000); let completed = SelfUpdateState::CompletedAt(ts); let json = serde_json::to_value(&completed).unwrap(); assert_eq!(json, serde_json::json!(1_700_000_000u64)); let back: SelfUpdateState = serde_json::from_value(json).unwrap(); assert_eq!(back, SelfUpdateState::CompletedAt(ts)); // Missing field defaults to NotRequired via #[serde(default)] let group_json = serde_json::json!({"mls_group_id": {"value": {"vec": [1,2,3]}}, "nostr_group_id": [0u8;32], "name": "t", "description": "", "admin_pubkeys": [], "last_message_id": null, "last_message_at": null, "last_message_processed_at": null, "epoch": 0, "state": "active"}); let g: Group = serde_json::from_value(group_json).unwrap(); assert_eq!(g.self_update_state, SelfUpdateState::NotRequired); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/mdk-storage-traits/src/groups/types.rs` around lines 35 - 60, Add a unit test named test_self_update_state_serde_roundtrip that verifies Serde roundtrips for SelfUpdateState: assert SelfUpdateState::NotRequired serializes to JSON null and deserializes back, SelfUpdateState::Required serializes to numeric 0u64 and deserializes back, and SelfUpdateState::CompletedAt(ts) serializes to the timestamp seconds and deserializes back (use Timestamp::from_secs for ts); also assert that deserializing a Group JSON missing self_update_state yields SelfUpdateState::NotRequired (to cover the #[serde(default]) behavior. Use serde_json::to_value/from_value and equality checks against expected serde_json::Value values to exercise the custom Serialize/Deserialize impls.crates/mdk-core/src/groups.rs (3)
1295-1306: Clarify the vacuous-truth behaviour ofall()on an empty iterator.
staged_commit.queued_proposals().all(|p| matches!(p.proposal(), Proposal::Update(_)))returnstruewhenqueued_proposals()is empty — and for aself_update_with_new_signercommit the iterator is always empty (the key rotation goes through the update path, not a queued proposal). This is the intended behaviour, but the comment only says "All proposals must be Update type", which implies there must be proposals. A reader unfamiliar with Rust's vacuous-allsemantics could misread this as "at least one Update proposal must exist".Consider adding a short inline clarification:
💬 Suggested comment tweak
- // All proposals must be Update type (no add/remove/psk/etc.) + // All queued proposals, if any, must be Update type (no add/remove/psk/etc.). + // NOTE: for a self_update_with_new_signer commit the queue is empty, + // so this vacuously returns `true` — that is intentional. let all_updates = staged_commit .queued_proposals() .all(|p| matches!(p.proposal(), Proposal::Update(_)));🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/mdk-core/src/groups.rs` around lines 1295 - 1306, The comment for the is_self_update check is misleading about staged_commit.queued_proposals().all(...) because Rust's all() returns true for an empty iterator; update-only semantics are vacuously true when there are no queued proposals (as in self_update_with_new_signer where the update comes via update_path_leaf_node()). Update the surrounding comment above the is_self_update calculation (referring to mls_group.pending_commit(), staged_commit.update_path_leaf_node(), staged_commit.update_proposals(), and staged_commit.queued_proposals()) to explicitly state that all(...) is vacuously true on empty iterators and that an empty queued_proposals() is expected for path-based updates, so the check means “no non-update proposals present” rather than “at least one Update proposal exists.”
1292-1327: Missing test: non-self-updatemerge_pending_commitmust not mutateself_update_state.The PR objective states "Non-self-update commits leave both fields unchanged", and the inline comment at Line 1317 repeats this guarantee. However, none of the existing tests assert this explicitly — for example, that
add_members+merge_pending_commitpreserves a pre-existingRequiredorCompletedAtstate. A targeted test (analogous to thetest_groups_needing_self_updatetest mentioned in the PR) would close this gap and guard against regressions.Would you like me to draft a test for this scenario?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/mdk-core/src/groups.rs` around lines 1292 - 1327, Add a unit test (e.g., test_merge_pending_commit_non_self_update_preserves_self_update_state) that sets up a group with a pre-existing self_update_state (both variants: Required and CompletedAt), performs a non-self-update operation such as add_members to produce a pending commit, calls merge_pending_commit(group_id) on the GroupStore, then reloads the group via get_group(group_id) and asserts that group.self_update_state is unchanged (compare to the original SelfUpdateState value). Use the existing helper setup used by other group tests, ensure the staged commit is indeed non-self-update (no update_path_leaf_node and includes an Add proposal), and fail the test if merge_pending_commit mutates self_update_state.
1318-1324: Two storage round-trips whenis_self_updateis true — consider consolidating.When
is_self_update, the flow performs:
sync_group_metadata_from_mls→get_group→ mutate non-self-update fields →save_groupget_group→ setself_update_state = CompletedAt(now)→save_groupThe second write reads back the group that was just persisted and saves it again with only one field changed. This is functionally correct because
sync_group_metadata_from_mlsintentionally preserves whateverself_update_stateis already stored, and the second write then overrides it. However the intermediate state (synced metadata, oldself_update_state) is briefly observable by concurrent readers of the storage layer.A lightweight fix is to apply the
self_update_stateupdate to the samestored_groupinsidesync_group_metadata_from_mls's write, or accept anOption<SelfUpdateState>override parameter. At minimum, the current two-write path warrants a short comment:♻️ Option A – pass override into a private sync helper (avoids second write)
- pub fn sync_group_metadata_from_mls(&self, group_id: &GroupId) -> Result<(), Error> { + pub fn sync_group_metadata_from_mls(&self, group_id: &GroupId) -> Result<(), Error> { + self.sync_group_metadata_from_mls_with_state(group_id, None) + } + + fn sync_group_metadata_from_mls_with_state( + &self, + group_id: &GroupId, + self_update_override: Option<group_types::SelfUpdateState>, + ) -> Result<(), Error> { let mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?; let mut stored_group = self.get_group(group_id)?.ok_or(Error::GroupNotFound)?; let group_data = NostrGroupDataExtension::from_group(&mls_group)?; stored_group.epoch = mls_group.epoch().as_u64(); stored_group.name = group_data.name; // ...existing field updates... + if let Some(state) = self_update_override { + stored_group.self_update_state = state; + } self.storage() .save_group(stored_group) .map_err(|e| Error::Group(e.to_string()))?; Ok(()) }Then in
merge_pending_commit:- self.sync_group_metadata_from_mls(group_id)?; - - if is_self_update { - let mut group = self.get_group(group_id)?.ok_or(Error::GroupNotFound)?; - group.self_update_state = group_types::SelfUpdateState::CompletedAt(Timestamp::now()); - self.storage() - .save_group(group) - .map_err(|e| Error::Group(e.to_string()))?; - } + let state_override = is_self_update + .then(|| group_types::SelfUpdateState::CompletedAt(Timestamp::now())); + self.sync_group_metadata_from_mls_with_state(group_id, state_override)?;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/mdk-core/src/groups.rs` around lines 1318 - 1324, When is_self_update is true we perform two storage round-trips (sync_group_metadata_from_mls -> save_group, then get_group -> modify self_update_state -> save_group); consolidate by applying the SelfUpdateState change in the same write: update sync_group_metadata_from_mls (or its private helper) to accept an Option<group_types::SelfUpdateState> override (or detect is_self_update and set group.self_update_state before calling save_group), then call that from merge_pending_commit so the stored_group written by sync_group_metadata_from_mls already has self_update_state = CompletedAt(Timestamp::now()), eliminating the extra get_group/save_group cycle and preventing the intermediate stale state from being observable.crates/mdk-sqlite-storage/src/lib.rs (1)
3856-3884: MissingSelfUpdateStateimport — relies onuse super::*;.
snapshot_openmls_testsusesSelfUpdateState::NotRequiredat line 3883 but doesn't import it explicitly (line 3860 only importsGroup, GroupState). It works viause super::*;but is fragile. Consider adding it to the explicit import for consistency with the siblingsnapshot_testsmodule (line 3264).Also, none of the snapshot tests exercise a
GroupwithSelfUpdateState::RequiredorCompletedAt(ts). Since the snapshot path serializes the rawlast_self_update_atcolumn (whereRequiredmaps toSome(0)andCompletedAtmaps toSome(ts)), a round-trip test with non-default state values would help catch encoding regressions.Suggested import fix
use mdk_storage_traits::groups::GroupStorage; - use mdk_storage_traits::groups::types::{Group, GroupState}; + use mdk_storage_traits::groups::types::{Group, GroupState, SelfUpdateState}; use mdk_storage_traits::mls_codec::MlsCodec;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@crates/mdk-sqlite-storage/src/lib.rs` around lines 3856 - 3884, The test module snapshot_openmls_tests relies on use super::* for SelfUpdateState but should import it explicitly and add round-trip cases for non-default states; update the module imports to include SelfUpdateState (alongside Group and GroupState) and extend create_test_group / the snapshot test cases to exercise at least one Group with SelfUpdateState::Required and one with SelfUpdateState::CompletedAt(ts) so the snapshot serialization of last_self_update_at (Required -> Some(0), CompletedAt -> Some(ts)) is validated end-to-end.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/mdk-core/src/epoch_snapshots.rs`:
- Around line 630-633: Tests currently contain local `use` statements inside
test functions; move the listed imports into the top of the `mod tests` module
scope. Specifically, remove the in-function uses of
mdk_storage_traits::groups::GroupStorage,
mdk_storage_traits::groups::types::{Group, GroupState, SelfUpdateState},
nostr::PublicKey, and std::collections::BTreeSet from the bodies of the tests
and add them once at the top of the `mod tests` block; repeat the same
relocation for the other occurrences referenced (around the sections you noted:
677-681, 831-835, 938-942). Ensure there are no duplicate imports after
consolidation.
In `@crates/mdk-storage-traits/src/groups/types.rs`:
- Line 24: The derive attribute on the type declared with #[derive(Debug, Clone,
Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] places Default in the
wrong position; update that derive list so it follows the project order (Debug,
Clone, Copy, PartialEq, Eq, Hash) and place Default at the end (e.g., Debug,
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default) to match the pattern
used by GroupState and satisfy the coding guideline; modify the derive on that
type accordingly.
---
Outside diff comments:
In `@crates/mdk-storage-traits/tests/cross_storage.rs`:
- Around line 113-118: Move the in-function imports out of
create_test_group_for_cross_storage and into the module scope (or the existing
mod shared) so they live at the top of the file; specifically, remove the `use`
line inside the function and add `use mdk_storage_traits::groups::types::{Group,
GroupState, SelfUpdateState};` at the top of the module so
create_test_group_for_cross_storage can reference Group, GroupState, and
SelfUpdateState directly.
---
Duplicate comments:
In `@crates/mdk-storage-traits/CHANGELOG.md`:
- Around line 28-74: The file contains duplicate "### Breaking changes" and "###
Added" headings introduced by PR `#184`; merge the two PR `#184` bullet points (the
SelfUpdateState item and the groups_needing_self_update() default method) into
the existing "### Breaking changes" and "### Added" sections respectively (so
the SelfUpdateState entry goes under the existing Breaking changes block and the
groups_needing_self_update() entry goes under the existing Added block), remove
the redundant "### Breaking changes" and "### Added" headings and any
surrounding blank lines, and ensure the section order follows Keep a Changelog
(Breaking changes, Changed, Added, Fixed, Removed, Deprecated).
---
Nitpick comments:
In `@crates/mdk-core/src/groups.rs`:
- Around line 1295-1306: The comment for the is_self_update check is misleading
about staged_commit.queued_proposals().all(...) because Rust's all() returns
true for an empty iterator; update-only semantics are vacuously true when there
are no queued proposals (as in self_update_with_new_signer where the update
comes via update_path_leaf_node()). Update the surrounding comment above the
is_self_update calculation (referring to mls_group.pending_commit(),
staged_commit.update_path_leaf_node(), staged_commit.update_proposals(), and
staged_commit.queued_proposals()) to explicitly state that all(...) is vacuously
true on empty iterators and that an empty queued_proposals() is expected for
path-based updates, so the check means “no non-update proposals present” rather
than “at least one Update proposal exists.”
- Around line 1292-1327: Add a unit test (e.g.,
test_merge_pending_commit_non_self_update_preserves_self_update_state) that sets
up a group with a pre-existing self_update_state (both variants: Required and
CompletedAt), performs a non-self-update operation such as add_members to
produce a pending commit, calls merge_pending_commit(group_id) on the
GroupStore, then reloads the group via get_group(group_id) and asserts that
group.self_update_state is unchanged (compare to the original SelfUpdateState
value). Use the existing helper setup used by other group tests, ensure the
staged commit is indeed non-self-update (no update_path_leaf_node and includes
an Add proposal), and fail the test if merge_pending_commit mutates
self_update_state.
- Around line 1318-1324: When is_self_update is true we perform two storage
round-trips (sync_group_metadata_from_mls -> save_group, then get_group ->
modify self_update_state -> save_group); consolidate by applying the
SelfUpdateState change in the same write: update sync_group_metadata_from_mls
(or its private helper) to accept an Option<group_types::SelfUpdateState>
override (or detect is_self_update and set group.self_update_state before
calling save_group), then call that from merge_pending_commit so the
stored_group written by sync_group_metadata_from_mls already has
self_update_state = CompletedAt(Timestamp::now()), eliminating the extra
get_group/save_group cycle and preventing the intermediate stale state from
being observable.
In `@crates/mdk-sqlite-storage/migrations/V004__add_self_update_tracking.sql`:
- Line 6: Add a non-blocking index for the new column to keep queries like
groups_needing_self_update(threshold_secs) fast as the table grows: after the
ALTER TABLE statement that adds last_self_update_at, create an index named
idx_groups_last_self_update_at on the groups(last_self_update_at) column using
CREATE INDEX IF NOT EXISTS so migration is idempotent and safe for production.
- Line 6: The migration adds a single column last_self_update_at that encodes
three states via NULL/0/>0; instead, add an explicit boolean-like flag to make
intent unambiguous: modify the migration that alters the groups table (ALTER
TABLE groups ADD COLUMN last_self_update_at INTEGER;) to also add a
needs_self_update INTEGER NOT NULL DEFAULT 0 column and keep last_self_update_at
as nullable; ensure application code that sets/reads last_self_update_at and the
new needs_self_update flag (e.g., the accept_welcome flow and any readers
expecting the magic 0 sentinel) are updated to set needs_self_update = 1 when an
update is pending and to use last_self_update_at IS NULL to mean "never
updated."
In `@crates/mdk-sqlite-storage/src/lib.rs`:
- Around line 3856-3884: The test module snapshot_openmls_tests relies on use
super::* for SelfUpdateState but should import it explicitly and add round-trip
cases for non-default states; update the module imports to include
SelfUpdateState (alongside Group and GroupState) and extend create_test_group /
the snapshot test cases to exercise at least one Group with
SelfUpdateState::Required and one with SelfUpdateState::CompletedAt(ts) so the
snapshot serialization of last_self_update_at (Required -> Some(0), CompletedAt
-> Some(ts)) is validated end-to-end.
In `@crates/mdk-storage-traits/src/groups/types.rs`:
- Around line 35-60: Add a unit test named
test_self_update_state_serde_roundtrip that verifies Serde roundtrips for
SelfUpdateState: assert SelfUpdateState::NotRequired serializes to JSON null and
deserializes back, SelfUpdateState::Required serializes to numeric 0u64 and
deserializes back, and SelfUpdateState::CompletedAt(ts) serializes to the
timestamp seconds and deserializes back (use Timestamp::from_secs for ts); also
assert that deserializing a Group JSON missing self_update_state yields
SelfUpdateState::NotRequired (to cover the #[serde(default]) behavior. Use
serde_json::to_value/from_value and equality checks against expected
serde_json::Value values to exercise the custom Serialize/Deserialize impls.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@crates/mdk-storage-traits/src/groups/types.rs`:
- Around line 14-24: The derive attribute ordering on the self-update tracking
type is incorrect: move Hash to directly after Eq so the derive sequence becomes
Debug, Clone, Copy, PartialEq, Eq, Hash, and then any remaining derives
(PartialOrd, Ord) after Hash; update the #[derive(...)] on the type that tracks
self-update (the enum with Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord,
Hash, Default) to reflect this exact ordering.
68f187b to
e9d60f4
Compare
Adds two fields to the Group struct: - `needs_self_update: bool` — set by accept_welcome(), cleared when a pure self-update commit is merged - `last_self_update_at: Option<Timestamp>` — updated every time a pure self-update commit is merged, enabling periodic rotation tracking The merge_pending_commit() method inspects the StagedCommit before merging to detect pure self-updates (update proposals only, no add/remove/psk), matching the validation logic in is_pure_self_update_commit(). Includes SQLite migration, UniFFI bindings, serde backward-compat defaults, and test updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Default trait method on GroupStorage that returns active groups needing a self-update: either post-join (needs_self_update=true) or stale rotation (last_self_update_at older than threshold). Exposed via MDK public API and UniFFI bindings. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace needs_self_update (bool) and last_self_update_at (Option<Timestamp>) with a single self_update_state: SelfUpdateState field on Group. The enum has three variants: NotRequired, Required (MIP-02 post-join), and CompletedAt(Timestamp) (MIP-00 periodic rotation). This eliminates impossible state combinations, reduces the SQLite migration to a single column (last_self_update_at INTEGER), and fixes the snapshot/ restore mechanism to preserve self-update state across MIP-03 rollbacks. Adds changelogs for mdk-storage-traits, mdk-core, mdk-sqlite-storage, and mdk-uniffi.
- Remove duplicate '### Breaking changes' and '### Added' headers in mdk-storage-traits and mdk-uniffi changelogs, merging PR marmot-protocol#184 entries into the existing sections. - Prefix the CompletedAt timestamp in UniFFI with 'completed_at:' so FFI clients can reliably distinguish state variants by string prefix rather than parsing an ambiguous bare integer.
…erde tests - Move in-function imports to module scope in epoch_snapshots.rs tests and cross_storage.rs (per AGENTS.md import placement rules) - Fix SelfUpdateState derive order: Default at end per project convention - Clarify is_self_update comment: all() is vacuously true on empty iterators, which is expected for path-based self-updates - Rename local variable all_updates to no_non_update_proposals for clarity - Add serde roundtrip tests for SelfUpdateState (NotRequired/Required/ CompletedAt) and verify #[serde(default)] behavior on Group
…store The snapshot_groups_table() and restore_group_from_snapshot() functions were missing the last_message_processed_at column added in V002 (PR marmot-protocol#166). After a MIP-03 commit race rollback, this column would reset to NULL, undoing the message ordering fix for that group.
The OwnCommitPending handler in process.rs called OpenMLS merge_pending_commit() directly, bypassing the self-update detection in MDK::merge_pending_commit(). After a rollback/reprocess of a self-update commit, self_update_state would remain Required instead of transitioning to CompletedAt, causing a redundant self-update.
8a4f102 to
8372885
Compare
mubarakcoded
left a comment
There was a problem hiding this comment.
Looks good, I think once the coverage drop is fixed, this is good to go.
Group creators now start with CompletedAt(now) since creating a group with a fresh key is effectively the first rotation. This ensures every group is always on the periodic rotation clock and eliminates the blind spot where creators were never flagged for staleness. The enum now has two variants: Required (post-join, MIP-02) and CompletedAt(Timestamp) (last rotation, MIP-00). The SQLite column becomes NOT NULL DEFAULT 0.
|
Thanks for the quick consideration, gents 🙏 |
Didn't want to get ahead of myself - will do this going forward |
Summary
needs_self_update: boolandlast_self_update_at: Option<Timestamp>to theGroupstruct, enabling MDK to track post-join self-update obligations (MIP-02) and periodic key rotation staleness (MIP-00) at the storage layermerge_pending_commit()inspects theStagedCommitto detect pure self-update commits before merging, then conditionally clears the flag and records the timestamp — non-self-update commits leave both fields untouchedgroups_needing_self_update(threshold_secs)query as a default trait method onGroupStorage, exposed via public API and UniFFI bindingsMotivation
During MIPs compliance review for both pika and Vector, we identified that self-update tracking state (
pending_self_updates, rotation timestamps) was being duplicated at the app layer. These are protocol-level requirements that belong in MDK so all consumers (pika, Vector, marmot-ts, future clients) get correct behavior for free.Changes
mdk-storage-traits: Two new fields onGroup,groups_needing_self_update()default trait method, test coveragemdk-core:accept_welcome()setsneeds_self_update = true;merge_pending_commit()detects self-updates viaStagedCommitAPI;create_group()initializes defaults; public API for querymdk-sqlite-storage: V004 migration adding columns, save/load supportmdk-memory-storage: Test struct updatesmdk-uniffi: Fields + query exposed to Swift/KotlinTest plan
test_groups_needing_self_updatecovers 5 cases: post-join flag, stale rotation, fresh rotation, inactive exclusion, creator-no-obligation🤖 Generated with Claude Code
This PR adds structured self-update tracking to groups and a query to find groups that need self-updates, implementing MIP-02 post-join obligations and MIP-00 periodic-rotation staleness checks. It replaces the previous two-field approach with a single SelfUpdateState enum, persists it in storage (SQLite migration), and exposes the query through core APIs and UniFFI bindings.
What changed
Protocol changes
API surface
Testing