Skip to content

feat(disputes): make the dispute chat survive a restart - #256

Open
grunch wants to merge 4 commits into
mainfrom
feat/dispute-chat-durability
Open

feat(disputes): make the dispute chat survive a restart#256
grunch wants to merge 4 commits into
mainfrom
feat/dispute-chat-durability

Conversation

@grunch

@grunch grunch commented Jul 30, 2026

Copy link
Copy Markdown
Member

Third in the series, stacked on #254re-target to main once that merges.

Problem

The dispute record is in-memory by design: status and resolution come back from daemon events. The solver pubkey does not. It arrives exactly once, in admin-took-dispute, and without it the dispute chat keys cannot be derived again.

So a restart mid-dispute left the party unable to reach the solver at all — and resubscribe_active_dispute_chats, added in the #254 review round, iterates the in-memory store, which after a restart is empty. It correctly handles the reconnect case (a listener that failed to start while online gets re-armed); the restart case had nothing to work with.

The dispute_admin:<order_id> key builder was already in db/mod.rs, but nothing read or wrote it.

Change

  • handle_admin_took_dispute writes the solver pubkey to the settings KV, on both paths (existing record, and the peer-opened dispute that PR feat(disputes): route admin-took-dispute so the app learns the solver #253 creates).
  • Rehydration folds into the function that already runs on reconnect rather than adding a second entry point and a second call site: before re-arming listeners, dispute records are rebuilt for persisted trades that have a stored solver.

Details worth reviewing:

  • Trades are the enumeration source, so no key-prefix scan is needed — the Storage trait has none.
  • Records already in memory win; they are at least as fresh as storage.
  • Restored status is InReview, which is exactly what a stored solver means. A later resolution arrives as a daemon event.
  • Restoring the record is also what makes submit_evidence work after a restart — it refuses without one.
  • Persistence is best-effort: a storage failure logs and leaves the live listener working for the session, rather than undoing an already-applied dispute update.

Scope note

This is a deliberate, narrow exception to "the dispute store is in-memory by design" (CLAUDE.md). Only the one field that cannot be re-derived is persisted — not status, not resolution, not the reason.

Test plan

  • cargo test — 209 passing. New: the solver pubkey survives closing and reopening the store (the restart this exists for), and each order gets its own key.
  • cargo clippy --all-targets — 24 warnings, the main baseline
  • cargo check --locked --target wasm32-unknown-unknown — passes

Summary by CodeRabbit

  • Bug Fixes
    • Dispute chats can now be restored after an application restart.
    • In-progress disputes retain the solver’s public key, allowing continued evidence submission.
    • Restores multiple disputes more reliably, using the correct solver data per order.
    • Prevents duplicate or conflicting dispute openings during concurrent updates.
    • Handles repeated updates and solver reassignment without losing dispute information.
    • Finished disputes are no longer resurrected during recovery.
  • Documentation
    • Added a “Persistence and restart” section describing dispute rehydration and terminal-state cleanup behavior.

The dispute record is in-memory by design — status and resolution come back
from daemon events. The solver pubkey does not: it arrives exactly once, in
`admin-took-dispute`, and without it the chat keys cannot be derived again. So
a restart mid-dispute left the party unable to reach the solver at all, and
`resubscribe_active_dispute_chats` (added in the #254 review round) found an
empty store to re-arm from.

`handle_admin_took_dispute` now writes the pubkey to the settings KV under
`dispute_admin:<order_id>`, on both the existing-record and the
peer-opened-dispute paths. The key builder already existed but nothing read or
wrote it.

Rehydration folds into the function that already runs on reconnect rather than
adding a second entry point: before re-arming, records are rebuilt for
persisted trades that have a stored solver. Trades are the enumeration source,
so no key-prefix scan is needed. Records already in memory win — they are at
least as fresh as storage. Restored status is InReview, which is what a stored
solver means; a later resolution arrives as a daemon event. That also restores
`submit_evidence`, which refuses without a record.

Persistence is best-effort: a storage failure logs and leaves the live listener
working for the session rather than undoing an applied dispute update.

Tests: the pubkey survives closing and reopening the store (the restart this
exists for), and each order gets its own key. 209 Rust tests, clippy at the
baseline 24, wasm32 check passes.
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Dispute handling now coordinates atomic local opens and admin assignments, persists solver pubkeys by order, and rehydrates active InReview records after reconnect. Finished disputes clear persisted keys, with tests and contracts covering ownership, replay, reassignment, restart behavior, and web limitations.

Changes

Dispute lifecycle

Layer / File(s) Summary
Atomic dispute ownership and assignment
rust/src/api/disputes.rs
Pending local opens reject active duplicates and atomically claim or preserve records during admin assignment, including same-solver replays, metadata preservation, and solver reassignment.
Persist and clean up dispute state
rust/src/api/disputes.rs
Solver pubkeys are stored under order-specific keys, terminal order statuses are classified, and persisted keys are removed after successful resolution.
Rehydrate disputes after reconnect
rust/src/api/disputes.rs, specs/004-mostro-p2p-client/contracts/disputes.md
Storage rehydration restores missing active disputes before listeners restart, preserves in-memory records, clears keys for finished trades, and documents restart behavior and web limitations. Tests cover these flows and per-order isolation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client as open_dispute
  participant Store as DisputeStore
  participant Admin as handle_admin_took_dispute
  participant Storage
  participant Reconnect as resubscribe_active_dispute_chats
  Client->>Store: Mark pending and insert local dispute
  Admin->>Store: Atomically create or update assignment
  Store-->>Admin: Preserve, claim, or replace record
  Admin->>Storage: Persist solver pubkey by order
  Reconnect->>Storage: Read trades and persisted solver keys
  Storage-->>Reconnect: Return statuses and solver pubkeys
  Reconnect->>Store: Restore missing InReview disputes
  Store->>Storage: Clear keys for finished trades
  Reconnect->>Store: Restart dispute listeners
Loading

Possibly related PRs

  • MostroP2P/app#253: Extends the same admin-took dispute flow with atomic updates, metadata preservation, replay handling, and solver reassignment.

Poem

A rabbit guards each solver key,
While disputes hop back faithfully.
Atomic paws prevent a race,
Replays return to their old place.
Finished keys are swept away—
Chats wake bright at break of day.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: preserving dispute chat functionality across application restarts.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/dispute-chat-durability

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.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 703370b9b7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rust/src/api/disputes.rs
Comment on lines +453 to +457
dispute_store()
.upsert(Dispute {
id: uuid::Uuid::new_v4().to_string(),
trade_id: order_id.clone(),
status: DisputeStatus::InReview,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip terminal trades during dispute rehydration

When a stored trade has already reached SettledByAdmin or CanceledByAdmin, list_trades() still returns it and the solver setting is never removed, so this unconditionally resurrects the dispute as InReview on every restart. The daemon dispatcher in orders.rs routes those terminal actions only through trade-status synchronization and never calls the dispute resolution handlers, leaving the restored dispute active indefinitely, starting an unnecessary listener and allowing further evidence submission. Filter terminal trade statuses here or clear the persisted solver key when resolving.

Useful? React with 👍 / 👎.

Comment thread rust/src/api/disputes.rs
Comment on lines +344 to +346
let Some(db) = crate::db::app_db::db() else {
log::warn!("[disputes] no store — solver pubkey will not survive a restart");
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Provide a web persistence path for solver keys

On Flutter web, lib/main.dart explicitly skips initDb, so app_db::db() is always None and every new persistence attempt returns here without writing. Even if the IndexedDB settings store were initialized, its list_trades() currently always returns an empty list, leaving rehydration with no enumeration source. Consequently, browser reloads still lose the solver pubkey and dispute chat despite this feature; web needs an initialized store plus an enumeration strategy that does not depend on unimplemented trade persistence.

Useful? React with 👍 / 👎.

Comment thread rust/src/api/disputes.rs
/// would stay invisible for the rest of the process. Idempotent — the
/// per-channel single-owner guard makes a spawn for an already-listening
/// dispute a no-op.
/// Rebuild dispute records for orders with a persisted solver pubkey.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Update the dispute contract for restart persistence

This introduces externally observable persistence and restart-rehydration behavior for get_dispute and submit_evidence, but specs/004-mostro-p2p-client/contracts/disputes.md remains unchanged and documents only the in-process lifecycle. Add the persisted solver-key and rehydration semantics, including terminal-state handling and platform limitations, to the matching contract as required for behavior changes.

AGENTS.md reference: AGENTS.md:L75-L75

Useful? React with 👍 / 👎.

Base automatically changed from feat/dispute-chat-envelope to main July 30, 2026 17:28
@grunch

grunch commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@MostroP2P MostroP2P deleted a comment from coderabbitai Bot Jul 30, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
rust/src/api/disputes.rs (1)

656-698: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Solid persistence tests, but the rehydration logic itself is untested.

the_solver_pubkey_outlives_the_in_memory_record and each_order_gets_its_own_solver_key only exercise the raw SqliteStorage get/set primitive. The more complex piece added in this PR — rehydrate_disputes_from_storage (trade enumeration, in-memory presence check, Dispute reconstruction) — has no direct test, so the race and staleness issues flagged above wouldn't have been caught by this suite.

As per coding guidelines, "Add targeted tests when expanding complex logic, asynchronous workflows, or protocol handling."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/src/api/disputes.rs` around lines 656 - 698, Extend the dispute tests to
directly cover rehydrate_disputes_from_storage rather than only SqliteStorage
primitives: seed persisted dispute-admin data and the required trade records,
invoke rehydrate_disputes_from_storage, and assert each eligible dispute is
reconstructed in memory with the correct order and solver key. Also cover the
existing in-memory presence/staleness behavior so rehydration does not overwrite
current records or retain stale entries.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/src/api/disputes.rs`:
- Around line 414-469: The resolve_dispute() flow must delete the persisted
dispute_admin(order_id) setting when resolution succeeds, in addition to
updating the in-memory dispute record. Reuse the existing app database and
dispute_admin key helper, and handle database access/errors consistently with
nearby persistence operations so rehydrate_disputes_from_storage() cannot
restore resolved disputes as InReview.
- Around line 453-466: Update the rehydrated Dispute construction in the
dispute_store upsert flow to set is_read to false for restored InReview
disputes, ensuring active disputes surface as needing attention after restart.

---

Nitpick comments:
In `@rust/src/api/disputes.rs`:
- Around line 656-698: Extend the dispute tests to directly cover
rehydrate_disputes_from_storage rather than only SqliteStorage primitives: seed
persisted dispute-admin data and the required trade records, invoke
rehydrate_disputes_from_storage, and assert each eligible dispute is
reconstructed in memory with the correct order and solver key. Also cover the
existing in-memory presence/staleness behavior so rehydration does not overwrite
current records or retain stale entries.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 36e3bbb2-925d-4ed7-baab-7ae222433b09

📥 Commits

Reviewing files that changed from the base of the PR and between 2464ade and 703370b.

📒 Files selected for processing (1)
  • rust/src/api/disputes.rs

Comment thread rust/src/api/disputes.rs
Comment thread rust/src/api/disputes.rs
@MostroP2P MostroP2P deleted a comment from coderabbitai Bot Jul 30, 2026
…ault, contract

- Clear the persisted `dispute_admin:<order_id>` key when a dispute resolves,
  and have rehydration skip (and clear) trades whose order status is finished.
  The stored solver key is what rehydration reads as "this dispute is live", so
  left behind it resurrected resolved disputes as InReview on every restart,
  armed a listener and kept accepting evidence. The trade status is the durable
  signal: the daemon's admin-settled/admin-canceled are persisted by the
  status-sync arm in orders.rs without being routed into the dispute store.

- Rehydrated records default to unread. The pre-restart read state cannot be
  recovered, and an active dispute must surface rather than be marked seen.

- Document the persistence and restart semantics in the disputes contract:
  the persisted field, rehydration rules, terminal-state handling, and the
  web limitation (no init_db off native, IndexedDB list_trades still the
  stub of #233), which is also noted at the rehydration site.

Tests: terminal vs live status classification, and the cleared key not
coming back across a store reopen.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rust/src/api/disputes.rs (1)

484-512: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Clear stale solver keys before returning from the cache hit.

When dispute_store().get(&order_id) returns a record, the loop continues before is_order_finished() can delete a leftover dispute-admin setting. If that record was created while the order was live, it can stay in memory while the order is terminal, so the persisted key is never cleaned up until the dispute record is dropped and rehydration runs again. Check the order status and call clear_admin_pubkey(&order_id).await before this early continue.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/src/api/disputes.rs` around lines 484 - 512, Update the cache-hit branch
in the trade rehydration loop so that, when dispute_store().get(&order_id)
returns a record, it checks is_order_finished(&trade.order.status) and calls
clear_admin_pubkey(&order_id).await before continuing. Preserve the existing
continue behavior for cached records and avoid changing the handling of cache
misses.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/src/api/disputes.rs`:
- Around line 759-820: Replace the primitive-only coverage in
clearing_the_solver_key_stops_the_dispute_coming_back,
finished_orders_are_not_rehydrated, and a_live_dispute_is_still_rehydrated with
a native SqliteStorage integration test that initializes app_db’s dispute store
and invokes rehydrate_disputes_from_storage. Set up persisted live and finished
dispute keys, verify live disputes are restored, finished entries are removed
via clear_admin_pubkey, and confirm an already in-memory dispute is not
duplicated.

---

Outside diff comments:
In `@rust/src/api/disputes.rs`:
- Around line 484-512: Update the cache-hit branch in the trade rehydration loop
so that, when dispute_store().get(&order_id) returns a record, it checks
is_order_finished(&trade.order.status) and calls
clear_admin_pubkey(&order_id).await before continuing. Preserve the existing
continue behavior for cached records and avoid changing the handling of cache
misses.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 6ae7174b-e432-4b6c-ac64-3a16e9cf0ec6

📥 Commits

Reviewing files that changed from the base of the PR and between 703370b and 3e21328.

📒 Files selected for processing (2)
  • rust/src/api/disputes.rs
  • specs/004-mostro-p2p-client/contracts/disputes.md

Comment thread rust/src/api/disputes.rs
grunch added 2 commits July 30, 2026 22:21
…lity

Conflict in `handle_admin_took_dispute`: main replaced the check-then-act
create path with a single `upsert_or_update` under one store write lock (PR
#253 review — a separate check races `open_dispute`'s post-publish insert in
both directions). Took main's version; the persistence this branch adds needed
no special case there, since the single `persist_admin_pubkey` call after the
store write already covers both the create and the update path.
Review round 2 on #256: the previous tests asserted on `is_order_finished`
and on the KV round trip in isolation, so nothing covered the pass that
actually uses them. This drives `rehydrate_disputes_from_storage` over a real
`SqliteStorage` with the app_db store initialized, seeding persisted trades
and solver keys, and asserts the three rules: a live dispute is restored as
InReview and unread, a finished one is skipped *and* its stale key deleted,
and an already-in-memory record is not overwritten by the older stored solver.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
rust/src/api/disputes.rs (1)

201-217: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Entry guard misses a concurrent in-flight open for the same trade.

The check only consults dispute_store(), which is still empty while another open_dispute for the same trade_id is between publish and insert. Both calls publish Action::Dispute; only the loser's insert fails. pending_opens() already tracks exactly this state — reuse it here.

🛡️ Proposed fix: reject when an open is already in flight
     if let Some(existing) = dispute_store().get(&trade_id).await {
         if existing.status != DisputeStatus::Resolved {
             bail!("DisputeAlreadyOpen: dispute already exists for trade {trade_id}");
         }
     }
-    pending_opens()
-        .lock()
-        .expect("pending_opens poisoned")
-        .insert(trade_id.clone());
+    // Claim the in-flight slot atomically: a false return means another
+    // open for this trade is already between publish and insert.
+    if !pending_opens()
+        .lock()
+        .map(|mut set| set.insert(trade_id.clone()))
+        .unwrap_or(false)
+    {
+        bail!("DisputeAlreadyOpen: an open request is already in flight for trade {trade_id}");
+    }
     let _pending = PendingOpenGuard(trade_id.clone());
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/src/api/disputes.rs` around lines 201 - 217, Update the entry guard in
open_dispute to also reject when pending_opens() already contains the same
trade_id, before publishing the dispute request. Reuse the existing
pending_opens tracking and preserve the current rejection behavior for existing
unresolved disputes and the subsequent PendingOpenGuard insertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@rust/src/api/disputes.rs`:
- Around line 201-217: Update the entry guard in open_dispute to also reject
when pending_opens() already contains the same trade_id, before publishing the
dispute request. Reuse the existing pending_opens tracking and preserve the
current rejection behavior for existing unresolved disputes and the subsequent
PendingOpenGuard insertion.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: b244d4bc-eee2-4b8d-b112-016023f96f52

📥 Commits

Reviewing files that changed from the base of the PR and between 3e21328 and 37b1964.

📒 Files selected for processing (1)
  • rust/src/api/disputes.rs

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant