Skip to content

feat: add self-update tracking fields to Group struct#184

Merged
erskingardner merged 8 commits intomarmot-protocol:masterfrom
alltheseas:feat/self-update-tracking
Feb 18, 2026
Merged

feat: add self-update tracking fields to Group struct#184
erskingardner merged 8 commits intomarmot-protocol:masterfrom
alltheseas:feat/self-update-tracking

Conversation

@alltheseas
Copy link
Contributor

@alltheseas alltheseas commented Feb 18, 2026

Summary

  • Adds needs_self_update: bool and last_self_update_at: Option<Timestamp> to the Group struct, enabling MDK to track post-join self-update obligations (MIP-02) and periodic key rotation staleness (MIP-00) at the storage layer
  • merge_pending_commit() inspects the StagedCommit to detect pure self-update commits before merging, then conditionally clears the flag and records the timestamp — non-self-update commits leave both fields untouched
  • Adds groups_needing_self_update(threshold_secs) query as a default trait method on GroupStorage, exposed via public API and UniFFI bindings

Motivation

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 on Group, groups_needing_self_update() default trait method, test coverage
  • mdk-core: accept_welcome() sets needs_self_update = true; merge_pending_commit() detects self-updates via StagedCommit API; create_group() initializes defaults; public API for query
  • mdk-sqlite-storage: V004 migration adding columns, save/load support
  • mdk-memory-storage: Test struct updates
  • mdk-uniffi: Fields + query exposed to Swift/Kotlin

Test plan

  • All 814 existing tests pass (struct literals updated across 9 files)
  • New test_groups_needing_self_update covers 5 cases: post-join flag, stale rotation, fresh rotation, inactive exclusion, creator-no-obligation
  • Runs against both SQLite and memory storage backends

🤖 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

  • Introduces a public SelfUpdateState enum (NotRequired, Required, CompletedAt(Timestamp)) and replaces needs_self_update + last_self_update_at with Group.self_update_state across crates.
  • Adds Group::groups_needing_self_update(threshold_secs) in mdk-core that delegates to storage to return active groups requiring self-update.
  • Implements a default groups_needing_self_update(threshold_secs) on the GroupStorage trait in mdk-storage-traits that filters active groups by SelfUpdateState::Required or CompletedAt older than threshold.
  • Updates merge_pending_commit() to detect pure self-update commits and set self_update_state = CompletedAt(now) on successful merges, leaving non-self-update commits unchanged.
  • Sets self_update_state = Required in accept_welcome() (post-join obligation) and initializes new groups with NotRequired in create_group().
  • Adds a SQLite migration V004 adding last_self_update_at INTEGER and maps it to SelfUpdateState in mdk-sqlite-storage row mapping and persistence.
  • Updates mdk-memory-storage implementations and tests to include the new self_update_state field.
  • Exposes self_update_state and groups_needing_self_update via UniFFI (mdk-uniffi) with stringified state values and a hex-encoded group ID result for the query.

Protocol changes

  • Implements MIP-02 post-join self-update obligation by marking joined groups Required until a self-update commit is merged.
  • Supports MIP-00 periodic rotation staleness checks by recording CompletedAt timestamps and allowing threshold-based stale detection.

API surface

  • Breaking change: public Group representation changed in mdk-storage-traits (and downstream crates) by replacing needs_self_update: bool and last_self_update_at: Option with self_update_state: SelfUpdateState.
  • New public API: groups_needing_self_update(threshold_secs: u64) added to MDK/GroupStorage surfaces (default trait impl in mdk-storage-traits, core wrapper in mdk-core).
  • Storage schema change: SQLite migration V004 adds last_self_update_at INTEGER with semantics NULL = no obligation, 0 = required, >0 = unix timestamp of last rotation.
  • UniFFI/FFI change: UniFFI Group now includes a self_update_state string and a new groups_needing_self_update() binding returning Vec group IDs.

Testing

  • Tests added: test_groups_needing_self_update covers post-join flag, stale rotation, fresh rotation, inactive exclusion, and creator-no-obligation cases.
  • Existing tests updated across mdk-core, mdk-sqlite-storage, and mdk-memory-storage to initialize and roundtrip the new self_update_state field, including serde roundtrip and defaulting tests.
  • All storage backends (SQLite and memory) run the new cross-storage test and pass in CI per the PR description.

@coderabbitai
Copy link

coderabbitai bot commented Feb 18, 2026

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces per-field self-update flags with a single public SelfUpdateState on Group, adds last_self_update_at storage column, exposes groups_needing_self_update(threshold_secs) across storage/MDK/UniFFI, marks groups Required on welcome, and transitions to CompletedAt(now) when a pure self-update commit is merged.

Changes

Cohort / File(s) Summary
Public types & tests
crates/mdk-storage-traits/src/groups/types.rs, crates/mdk-storage-traits/src/test_utils.rs, crates/mdk-storage-traits/tests/shared/mod.rs, crates/mdk-memory-storage/src/lib.rs, crates/mdk-sqlite-storage/src/lib.rs
Add public enum SelfUpdateState { NotRequired, Required, CompletedAt(Timestamp) } and new Group field self_update_state: SelfUpdateState; update test helpers/fixtures to initialize self_update_state (NotRequired).
Storage trait & tests
crates/mdk-storage-traits/src/groups/mod.rs, crates/mdk-storage-traits/tests/shared/group_tests.rs, crates/mdk-storage-traits/tests/*_storage.rs
Add fn groups_needing_self_update(&self, threshold_secs: u64) -> Result<Vec<GroupId>, GroupError> to GroupStorage (default impl filters active groups by SelfUpdateState::Required or stale CompletedAt); add cross-storage tests.
Core MDK logic
crates/mdk-core/src/groups.rs, crates/mdk-core/src/welcomes.rs, crates/mdk-core/src/epoch_snapshots.rs
Initialize self_update_state and last_self_update_at for new groups; set self_update_state = Required on accept_welcome; detect pure self-update commits in merge_pending_commit and set self_update_state = CompletedAt(now) and persist; expose groups_needing_self_update on MDK.
SQLite storage implementation
crates/mdk-sqlite-storage/migrations/V004__add_self_update_tracking.sql, crates/mdk-sqlite-storage/src/db.rs, crates/mdk-sqlite-storage/src/groups.rs, crates/mdk-sqlite-storage/src/messages.rs
Add last_self_update_at INTEGER via migration; map last_self_update_atSelfUpdateState (NULL→NotRequired, 0→Required, >0→CompletedAt(ts)); extend INSERT/UPDATE, snapshot/restore, and tests to include the column.
Memory storage & messages
crates/mdk-memory-storage/src/groups.rs, crates/mdk-memory-storage/src/messages.rs
Update in-memory storage structs and test fixtures to include self_update_state (initialized to NotRequired).
UniFFI bindings
crates/mdk-uniffi/src/lib.rs
Expose groups_needing_self_update(threshold_secs) and add self_update_state: String to UniFFI Group representation; map SelfUpdateState to string or timestamp.
Snapshots & epoch handling
crates/mdk-core/src/epoch_snapshots.rs, crates/mdk-sqlite-storage/src/lib.rs
Include last_self_update_at in snapshot/restore flows so SelfUpdateState persists across snapshots and restores.
Changelogs & docs
crates/mdk-core/CHANGELOG.md, crates/mdk-sqlite-storage/CHANGELOG.md, crates/mdk-storage-traits/CHANGELOG.md, crates/mdk-uniffi/CHANGELOG.md
Document new self-update tracking, migration, groups_needing_self_update API, and public-type/signature changes.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant Client
participant MDK
participant Storage
participant DB
Client->>MDK: join group (welcome)
MDK->>Storage: create/update Group (self_update_state = Required)
Storage->>DB: persist Group row (last_self_update_at = 0)
Client->>MDK: submit commit (possible self-update)
MDK->>Storage: fetch staged commit
MDK->>MDK: detect pure self-update commit (all proposals = Update + update signal)
alt is self-update
MDK->>Storage: merge commit and set self_update_state = CompletedAt(now)
Storage->>DB: update last_self_update_at = now (persist)
else not self-update
MDK->>Storage: merge commit (no self_update_state change)
end

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

breaking-change, storage, mls-protocol

Suggested reviewers

  • jgmontoya
  • dannym-arx
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding self-update tracking fields to the Group struct, which is the primary focus across all modified files.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
No Sensitive Identifier Leakage ✅ Passed The PR does not introduce sensitive identifier leakage; new self_update_state field contains only non-sensitive data and group IDs are properly handled without exposure.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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 | 🔴 Critical

Redact sensitive identifiers in Group’s Debug output.

Group derives Debug while carrying mls_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 for groups_needing_self_update queries.

groups_needing_self_update() will scan the entire groups table and filter on needs_self_update and last_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)] for needs_self_update.

This preserves backward-compatible deserialization of older serialized Group payloads 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.

Copy link
Contributor Author

@alltheseas alltheseas left a comment

Choose a reason for hiding this comment

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

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_nonce are already wrapped in Secret<> 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 calls all_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.

Copy link
Contributor Author

@alltheseas alltheseas left a comment

Choose a reason for hiding this comment

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

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_nonce are already wrapped in Secret<> 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 calls all_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 alltheseas force-pushed the feat/self-update-tracking branch from 717458a to 1abd9d4 Compare February 18, 2026 00:46
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 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: false and last_self_update_at: None, but test_group_serialization never 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.

@erskingardner
Copy link
Member

@alltheseas you will need to add commit signatures before we can accept any of your PRs on our repos.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
crates/mdk-uniffi/src/lib.rs (1)

1182-1209: Consider a less stringly-typed self_update_state for 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.

erskingardner added a commit to alltheseas/mdk that referenced this pull request Feb 18, 2026
- 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.
@erskingardner erskingardner force-pushed the feat/self-update-tracking branch from a3ca599 to b449ba0 Compare February 18, 2026 08:53
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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 | 🟡 Minor

Move in-function imports to module scope.

The use inside create_test_group_for_cross_storage violates the repo rule for import placement. Move it to the top of the module (or mod 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 use statements 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 if groups_needing_self_update is called frequently.

The new groups_needing_self_update(threshold_secs) query will filter on last_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" with NULL and "obligation pending" with the sentinel 0 means 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 by accept_welcome) and last_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 custom SelfUpdateState serde roundtrip.

The sentinel value 0 for Required is a non-obvious encoding. The existing test_group_serialization test doesn't assert on self_update_state, and there's no test that exercises Required ↔ Some(0), CompletedAt(ts) ↔ Some(ts), and NotRequired ↔ 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(&not_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 of all() on an empty iterator.

staged_commit.queued_proposals().all(|p| matches!(p.proposal(), Proposal::Update(_))) returns true when queued_proposals() is empty — and for a self_update_with_new_signer commit 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-all semantics 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-update merge_pending_commit must not mutate self_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_commit preserves a pre-existing Required or CompletedAt state. A targeted test (analogous to the test_groups_needing_self_update test 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 when is_self_update is true — consider consolidating.

When is_self_update, the flow performs:

  1. sync_group_metadata_from_mlsget_group → mutate non-self-update fields → save_group
  2. get_group → set self_update_state = CompletedAt(now)save_group

The 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_mls intentionally preserves whatever self_update_state is already stored, and the second write then overrides it. However the intermediate state (synced metadata, old self_update_state) is briefly observable by concurrent readers of the storage layer.

A lightweight fix is to apply the self_update_state update to the same stored_group inside sync_group_metadata_from_mls's write, or accept an Option<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: Missing SelfUpdateState import — relies on use super::*;.

snapshot_openmls_tests uses SelfUpdateState::NotRequired at line 3883 but doesn't import it explicitly (line 3860 only imports Group, GroupState). It works via use super::*; but is fragile. Consider adding it to the explicit import for consistency with the sibling snapshot_tests module (line 3264).

Also, none of the snapshot tests exercise a Group with SelfUpdateState::Required or CompletedAt(ts). Since the snapshot path serializes the raw last_self_update_at column (where Required maps to Some(0) and CompletedAt maps to Some(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.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

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.

@erskingardner erskingardner force-pushed the feat/self-update-tracking branch 2 times, most recently from 68f187b to e9d60f4 Compare February 18, 2026 10:50
alltheseas and others added 7 commits February 18, 2026 12:02
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.
@erskingardner erskingardner force-pushed the feat/self-update-tracking branch from 8a4f102 to 8372885 Compare February 18, 2026 11:04
Copy link
Contributor

@mubarakcoded mubarakcoded left a comment

Choose a reason for hiding this comment

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

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.
Copy link
Contributor

@dannym-arx dannym-arx left a comment

Choose a reason for hiding this comment

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

LGTM!

@erskingardner erskingardner merged commit f45c48b into marmot-protocol:master Feb 18, 2026
15 checks passed
@alltheseas
Copy link
Contributor Author

Thanks for the quick consideration, gents 🙏

@alltheseas
Copy link
Contributor Author

@alltheseas you will need to add commit signatures before we can accept any of your PRs on our repos.

Didn't want to get ahead of myself - will do this going forward

@coderabbitai coderabbitai bot mentioned this pull request Mar 1, 2026
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.

4 participants