Add read and read-write dispute solver permissions#708
Add read and read-write dispute solver permissions#708mostronatorcoder[bot] wants to merge 5 commits intomainfrom
Conversation
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
WalkthroughImplements solver permission levels (read vs read-write), parses optional permission in Changes
Sequence DiagramsequenceDiagram
participant Client as Admin Handler
participant Dispute as Dispute Service
participant DB as Database
participant Daemon as Daemon Logic
participant Error as Error Response
rect rgba(200,150,255,0.5)
Note over Client,Error: Permission Authorization Flow
end
Client->>Dispute: Load dispute (order ID, solver pubkey)
Dispute-->>Client: current solver pubkey, order ID
Client->>DB: solver_has_write_permission(solver_pubkey, order_id)
DB-->>Client: true / false
alt true (category=2)
Client->>Daemon: proceed with admin_settle/admin_cancel
Daemon->>Daemon: execute fund-affecting operations
Daemon-->>Client: success
else false (category=1)
Client->>Error: return CantDoReason::NotAuthorized
Error-->>Client: reject action
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related issues
Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 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: 4
🧹 Nitpick comments (1)
src/db.rs (1)
784-809: Document this public permission helper.Lines 784-809 encode a non-obvious auth contract:
truemeans the dispute is assigned tosolver_pubkeyand that user is in the read-write solver category. Please add a///comment so callers do not have to infer thecategory = 2rule from the SQL.As per coding guidelines, "Document non-obvious public APIs with
///doc comments".🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/db.rs` around lines 784 - 809, Document the non-obvious auth contract in the public helper solver_has_write_permission: add a /// doc comment above the function that explains that a returned true means the given solver_pubkey is assigned to the dispute (matches disputes.solver_pubkey and order_id) AND the corresponding user has is_solver = true and category = 2 (i.e., belongs to the read-write solver category); reference the fields checked (disputes.solver_pubkey, order_id, users.is_solver, users.category) so callers don't need to infer the "category = 2" rule from the SQL.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/ORDERS_AND_ACTIONS.md`:
- Line 41: Update the docs entry for the admin-add-solver command to describe
the full accepted payload syntax: state that the payload may be a bare public
key (interpreted as read-write), the public key with suffix :read, :write (alias
for read-write), or :read-write, and show examples for each; reference the
admin-add-solver command name and the parser behavior so readers know the bare
pubkey and :write alias are valid and equivalent to read-write.
In `@src/app/admin_add_solver.rs`:
- Around line 33-37: The code currently treats Some("") (an empty permission
token from parts.next()) as SOLVER_CATEGORY_READ_WRITE which silently grants
write access for inputs like "npub1test:", so change the match in
admin_add_solver.rs to treat Some("") as invalid by removing Some("") from the
read-write arm and handling it to return
Err(MostroCantDo(CantDoReason::InvalidParameters)); ensure the match still maps
Some("read") to SOLVER_CATEGORY_READ_ONLY and Some("read-write")/Some("write")
to SOLVER_CATEGORY_READ_WRITE, and add a regression test asserting that an input
like "npub1test:" yields MostroCantDo(CantDoReason::InvalidParameters).
- Around line 72-84: The code currently logs add_new_user failures but proceeds
to send the AdminAddSolver success DM; change the match on add_new_user to
return early on Err instead of continuing: in the Err(ee) arm log the error and
return Err(MostroInternalErr(ServiceError::DatabaseError(ee.to_string()))) (or
map ee into the appropriate ServiceError variant your codebase uses), so only
the Ok(r) path constructs Message::new_dispute and calls send_dm; update
references involved: add_new_user, Message::new_dispute, send_dm,
MostroInternalErr and ServiceError.
In `@src/app/admin_settle.rs`:
- Around line 216-223: Add behavioral tests that exercise the solver permission
gate by invoking admin_settle_action and admin_cancel_action with two solver
fixtures: one configured as read-only and one with read-write permissions. For
each action, call admin_settle_action and admin_cancel_action using the
read-only solver and assert the handler returns
MostroCantDo(CantDoReason::NotAuthorized); then call the same handlers with the
read-write solver and assert successful execution (no CantDo error). Use the
existing test helpers/fixtures to construct solvers with the appropriate
permission flags and reference the functions admin_settle_action,
admin_cancel_action, the error enum CantDoReason::NotAuthorized, and the wrapper
variant MostroCantDo when asserting results.
---
Nitpick comments:
In `@src/db.rs`:
- Around line 784-809: Document the non-obvious auth contract in the public
helper solver_has_write_permission: add a /// doc comment above the function
that explains that a returned true means the given solver_pubkey is assigned to
the dispute (matches disputes.solver_pubkey and order_id) AND the corresponding
user has is_solver = true and category = 2 (i.e., belongs to the read-write
solver category); reference the fields checked (disputes.solver_pubkey,
order_id, users.is_solver, users.category) so callers don't need to infer the
"category = 2" rule from the SQL.
🪄 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
Run ID: fe6387d0-822a-4a75-b7f7-6ffb8b3206f9
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock
📒 Files selected for processing (10)
Cargo.tomldocs/ADMIN_RPC_AND_DISPUTES.mddocs/ARCHITECTURE.mddocs/ORDERS_AND_ACTIONS.mddocs/README.mddocs/SOLVER_PERMISSION_LEVELS.mdsrc/app/admin_add_solver.rssrc/app/admin_cancel.rssrc/app/admin_settle.rssrc/db.rs
| // New error for authenticated callers lacking enough permissions | ||
| let unauthorized_error = CantDoReason::NotAuthorized; | ||
| assert_eq!(format!("{:?}", unauthorized_error), "NotAuthorized"); | ||
|
|
||
| // Verify they are different error types | ||
| assert_ne!(regular_error, admin_error); | ||
| assert_ne!(admin_error, unauthorized_error); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n --type rust -C3 '\b(admin_settle_action|admin_cancel_action|solver_has_write_permission|NotAuthorized)\b'Repository: MostroP2P/mostro
Length of output: 6405
🏁 Script executed:
#!/bin/bash
# Get the full test module from admin_settle.rs
fd "admin_settle.rs" -type f | head -1 | xargs wc -lRepository: MostroP2P/mostro
Length of output: 249
🏁 Script executed:
# Read the test module section from admin_settle.rs
fd "admin_settle.rs" -type f | head -1 | xargs tail -100Repository: MostroP2P/mostro
Length of output: 230
🏁 Script executed:
# Also check admin_cancel.rs tests
fd "admin_cancel.rs" -type f | head -1 | xargs tail -100Repository: MostroP2P/mostro
Length of output: 230
🏁 Script executed:
#!/bin/bash
# Find and display the test module from admin_settle.rs
find . -name "admin_settle.rs" | head -1 | xargs tail -150Repository: MostroP2P/mostro
Length of output: 5708
🏁 Script executed:
# Find and display the test module from admin_cancel.rs
find . -name "admin_cancel.rs" | head -1 | xargs tail -150Repository: MostroP2P/mostro
Length of output: 5759
🏁 Script executed:
#!/bin/bash
# Check full length and tail of admin_cancel.rs to see if there's a test module
find . -name "admin_cancel.rs" | head -1 | xargs wc -lRepository: MostroP2P/mostro
Length of output: 89
🏁 Script executed:
# Search for test functions that might exercise the handlers with different permissions
rg -n --type rust '(#\[test\]|fn test_.*admin_(settle|cancel))' src/app/Repository: MostroP2P/mostro
Length of output: 1168
🏁 Script executed:
# Look for any integration tests or tests that call admin_settle_action or admin_cancel_action
rg -n --type rust 'admin_(settle|cancel)_action' src/ | grep -i testRepository: MostroP2P/mostro
Length of output: 42
🏁 Script executed:
#!/bin/bash
# Check the end of admin_cancel.rs to confirm whether it has a test module
find . -name "admin_cancel.rs" | head -1 | xargs tail -50Repository: MostroP2P/mostro
Length of output: 2086
🏁 Script executed:
# Search for any tests or fixtures that might use solver permissions
rg -n --type rust 'solver.*permission|read.*only.*solver|write.*permission' src/ -iRepository: MostroP2P/mostro
Length of output: 679
🏁 Script executed:
#!/bin/bash
# Final confirmation: check if solver_has_write_permission is actually tested anywhere
rg -n --type rust 'solver_has_write_permission' src/ -A 5 | grep -A 5 'test\|#\[test\]'Repository: MostroP2P/mostro
Length of output: 42
Add behavioral tests for the new solver permission gate.
The current test at lines 216-223 only verifies that CantDoReason::NotAuthorized exists as an enum variant via debug formatting. It does not exercise the authorization check in admin_settle_action or admin_cancel_action.
Since this PR introduces write-permission enforcement (lines 53-54 in admin_settle.rs and lines 76-77 in admin_cancel.rs), add at least one test that:
- Calls
admin_settle_actionwith a read-only solver and asserts the handler returnsMostroCantDo(NotAuthorized) - Calls
admin_settle_actionwith a read-write solver and asserts successful execution
Repeat the same for admin_cancel_action to confirm the authorization boundary works end-to-end.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/app/admin_settle.rs` around lines 216 - 223, Add behavioral tests that
exercise the solver permission gate by invoking admin_settle_action and
admin_cancel_action with two solver fixtures: one configured as read-only and
one with read-write permissions. For each action, call admin_settle_action and
admin_cancel_action using the read-only solver and assert the handler returns
MostroCantDo(CantDoReason::NotAuthorized); then call the same handlers with the
read-write solver and assert successful execution (no CantDo error). Use the
existing test helpers/fixtures to construct solvers with the appropriate
permission flags and reference the functions admin_settle_action,
admin_cancel_action, the error enum CantDoReason::NotAuthorized, and the wrapper
variant MostroCantDo when asserting results.
There was a problem hiding this comment.
I did not implement the suggested admin_settle_action / admin_cancel_action success-path behavioral tests in this pass, because after verifying the current code they are not a direct bug fix and would require building non-trivial DB/LND fixtures to make the tests meaningful rather than synthetic. I’d prefer to handle that as a separate focused test-coverage improvement if needed.
There was a problem hiding this comment.
@grunch, understood — that's a reasonable call. Wiring up admin_settle_action / admin_cancel_action end-to-end in tests requires a live SQLite pool, mock LND connector, and valid Nostr key fixtures, which is a non-trivial undertaking that goes well beyond the scope of this change. The structural check you've added confirms the NotAuthorized variant is present and distinct, which keeps the CI signal intact.
Would you like me to open a follow-up GitHub issue to track adding proper behavioral coverage for the solver write-permission gate in admin_settle_action and admin_cancel_action? I can link back to this PR and the relevant lines as context.
🧠 Learnings used
Learnt from: arkanoider
Repo: MostroP2P/mostro PR: 530
File: src/app/order.rs:278-279
Timestamp: 2025-10-09T14:06:50.067Z
Learning: In the Mostro project, tests in files like `src/app/order.rs` may use structural checks to ensure functions like `order_action` do not panic during message construction, rather than validating specific error variants.
Learnt from: CR
Repo: MostroP2P/mostro PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-25T20:58:01.066Z
Learning: PRs must link the motivating issue, include `cargo test` output, and call out schema or config changes to ease verification
Learnt from: grunch
Repo: MostroP2P/mostro PR: 566
File: src/nip33.rs:1-3
Timestamp: 2026-01-16T12:05:35.401Z
Learning: Do not redefine NOSTR_REPLACEABLE_EVENT_KIND in files like src/config/constants.rs or import it explicitly. If mostro_core::prelude::* is imported, the constant is available automatically. Rely on the prelude wildcard import across Rust files; if a file compiles without a local definition, no import or definition is needed.
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
gh issue create \
--repo MostroP2P/mostro \
--title "Add behavioral tests for solver write-permission gate in admin_settle_action and admin_cancel_action" \
--body "## Context
This issue was raised during review of PR `#708` (Add read and read-write dispute solver permissions) in comment https://github.com/MostroP2P/mostro/pull/708#discussion_r3094389866, requested by `@grunch`.
## Problem
PR `#708` introduced write-permission enforcement in \`admin_settle_action\` (\`src/app/admin_settle.rs\`, lines 53–54) and \`admin_cancel_action\` (\`src/app/admin_cancel.rs\`, lines 76–77) via \`solver_has_write_permission\`. The only test currently covering this path is a structural check that verifies \`CantDoReason::NotAuthorized\` exists as an enum variant; it does **not** exercise the authorization boundary end-to-end.
## Goal
Add behavioral tests that:
1. Call \`admin_settle_action\` with a **read-only** solver fixture and assert the handler returns \`MostroCantDo(CantDoReason::NotAuthorized)\`.
2. Call \`admin_settle_action\` with a **read-write** solver fixture and assert successful execution (no \`CantDo\` error).
3. Repeat steps 1–2 for \`admin_cancel_action\`.
These tests require non-trivial fixtures (in-memory SQLite pool seeded with dispute + solver rows, mock LND connector, valid Nostr key pairs), which is why they were deferred from PR `#708`.
## Acceptance Criteria
- [ ] At least one test per action (\`admin_settle_action\`, \`admin_cancel_action\`) that covers the read-only rejection path.
- [ ] At least one test per action that covers the read-write success path (or reaches the next meaningful gate without a permission error).
- [ ] Tests use the existing test helpers/fixtures in the Mostro codebase where possible.
- [ ] \`cargo test\` passes with all new tests included.
## References
- PR `#708`: https://github.com/MostroP2P/mostro/pull/708
- Closes issue `#707` (parent feature)
- Relevant files: \`src/app/admin_settle.rs\`, \`src/app/admin_cancel.rs\`, \`src/db.rs\` (\`solver_has_write_permission\`)
" \
--assignee grunch
Length of output: 46
@grunch, done! Follow-up issue created and assigned to you: #709
It captures the full context — the deferred behavioral tests for the read-only rejection path and read-write success path in both admin_settle_action and admin_cancel_action, along with links back to this PR and the relevant source files.
🧠 Learnings used
Learnt from: arkanoider
Repo: MostroP2P/mostro PR: 530
File: src/app/order.rs:278-279
Timestamp: 2025-10-09T14:06:50.067Z
Learning: In the Mostro project, tests in files like `src/app/order.rs` may use structural checks to ensure functions like `order_action` do not panic during message construction, rather than validating specific error variants.
Learnt from: CR
Repo: MostroP2P/mostro PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-03-25T20:58:01.066Z
Learning: PRs must link the motivating issue, include `cargo test` output, and call out schema or config changes to ease verification
Learnt from: grunch
Repo: MostroP2P/mostro PR: 566
File: src/nip33.rs:1-3
Timestamp: 2026-01-16T12:05:35.401Z
Learning: Do not redefine NOSTR_REPLACEABLE_EVENT_KIND in files like src/config/constants.rs or import it explicitly. If mostro_core::prelude::* is imported, the constant is available automatically. Rely on the prelude wildcard import across Rust files; if a file compiles without a local definition, no import or definition is needed.
There was a problem hiding this comment.
Good catch. I implemented the behavior change so a read-write solver can take over a dispute that is already in-progress when the current assignee is a read-only solver, while keeping the existing restrictions for other reassignment cases.
Fix commit: 08fe6aa (Allow write solvers to take over read-only disputes).
Note: I pushed it to branch solver-permissions while preparing the update locally, so it still needs to be moved onto this PR branch before the thread can be marked fully resolved here.
There was a problem hiding this comment.
Skipped: comment is from another GitHub bot.
There was a problem hiding this comment.
Fixed in 08fe6aa and now pushed to the actual PR branch (feat/solver-read-write-permissions). This allows a read-write solver to take over a dispute already taken by a read-only solver, while preserving the existing restrictions for other reassignment cases.
There was a problem hiding this comment.
Skipped: comment is from another GitHub bot.
Catrya
left a comment
There was a problem hiding this comment.
If a solver with read-only permissions takes a dispute, a solver with read-write permissions cannot take it, Mostro returns cant_do: invalid_pubkey.
Therefore, that solver will not be able to send settleorder or cancelorder.
Currently, only the admin can take a dispute that another solver has already taken, this behavior is inherited from main.
I believe this should be changed to allow a solver with read-write permissions to take a dispute that has already been taken by a solver with read-only permissions.
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/app/admin_take_dispute.rs (1)
107-140: Optional: flatten the nestedifchain and hoist the imports.The
InProgressbranch has grown 4–5 levels deep, which hurts readability, andcrate::app::admin_add_solver::SOLVER_CATEGORY_READ_ONLYis referenced by fully-qualified path inline. Consider early returns plus a top-leveluse:♻️ Proposed refactor
-use crate::db::{find_solver_pubkey, is_user_present, solver_has_write_permission}; +use crate::app::admin_add_solver::SOLVER_CATEGORY_READ_ONLY; +use crate::db::{find_solver_pubkey, is_user_present, solver_has_write_permission};- if let Ok(solver) = find_solver_pubkey(pool, ev_pubkey.to_string()).await { - if solver.is_solver != 0_i64 { - if status == DisputeStatus::Initiated { - return true; - } - - if status == DisputeStatus::InProgress { - let sender_pubkey = ev_pubkey.to_string(); - if current_solver_pubkey == Some(sender_pubkey.as_str()) { - return true; - } - - if let Some(current_solver_pubkey) = current_solver_pubkey { - let sender_can_write = - solver_has_write_permission(pool, sender_pubkey.as_str(), dispute_order_id) - .await - .unwrap_or(false); - - if sender_can_write { - if let Ok(current_solver) = - find_solver_pubkey(pool, current_solver_pubkey.to_string()).await - { - if current_solver.is_solver != 0_i64 - && current_solver.category - == crate::app::admin_add_solver::SOLVER_CATEGORY_READ_ONLY - { - return true; - } - } - } - } - } - } - } + let Ok(solver) = find_solver_pubkey(pool, ev_pubkey.to_string()).await else { + return false; + }; + if solver.is_solver == 0_i64 { + return false; + } + match status { + DisputeStatus::Initiated => return true, + DisputeStatus::InProgress => { + let sender_pubkey = ev_pubkey.to_string(); + if current_solver_pubkey == Some(sender_pubkey.as_str()) { + return true; + } + // Takeover path: sender must have write permission and current solver must be read-only. + // NOTE: depends on the fix for the critical comment above. + // ... + } + _ => {} + }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/app/admin_take_dispute.rs` around lines 107 - 140, The InProgress branch in admin_take_dispute.rs is deeply nested; refactor by early-returning on negative checks (e.g., if find_solver_pubkey(...) fails or solver.is_solver == 0 return false; if status != DisputeStatus::InProgress return false) and pull crate::app::admin_add_solver::SOLVER_CATEGORY_READ_ONLY into a top-level use so you can compare current_solver.category == SOLVER_CATEGORY_READ_ONLY without the fully-qualified path; also locally bind sender_pubkey and current_solver_pubkey to variables, then call solver_has_write_permission and find_solver_pubkey with those bindings and return true as soon as the conditions are satisfied to flatten the nesting.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/app/admin_take_dispute.rs`:
- Around line 119-137: The current branch in admin_take_dispute.rs is dead
because solver_has_write_permission queries disputes.solver_pubkey so a
prospective taker (sender_pubkey != current solver) will always get false; add a
new helper (e.g., user_has_solver_write_permission) in db.rs that checks users
by pubkey for is_solver = true AND category = 2, or alternatively modify
solver_has_write_permission to accept a mode that checks only the users table,
then call that helper from admin_take_dispute (replace the
solver_has_write_permission call) to allow write-capable solvers to take over
read-only disputes; update tests to add a regression test where a write-capable
solver takes over an InProgress dispute assigned to a read-only solver.
---
Nitpick comments:
In `@src/app/admin_take_dispute.rs`:
- Around line 107-140: The InProgress branch in admin_take_dispute.rs is deeply
nested; refactor by early-returning on negative checks (e.g., if
find_solver_pubkey(...) fails or solver.is_solver == 0 return false; if status
!= DisputeStatus::InProgress return false) and pull
crate::app::admin_add_solver::SOLVER_CATEGORY_READ_ONLY into a top-level use so
you can compare current_solver.category == SOLVER_CATEGORY_READ_ONLY without the
fully-qualified path; also locally bind sender_pubkey and current_solver_pubkey
to variables, then call solver_has_write_permission and find_solver_pubkey with
those bindings and return true as soon as the conditions are satisfied to
flatten the nesting.
🪄 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
Run ID: 03cd3ccf-facd-4232-9107-801d8c9db064
📒 Files selected for processing (4)
docs/ORDERS_AND_ACTIONS.mdsrc/app/admin_add_solver.rssrc/app/admin_take_dispute.rssrc/db.rs
🚧 Files skipped from review as they are similar to previous changes (3)
- src/db.rs
- src/app/admin_add_solver.rs
- docs/ORDERS_AND_ACTIONS.md
solver_has_write_permission joins disputes on the assigned solver, so the InProgress takeover check always returned false for a prospective taker. Introduce user_has_solver_write_permission (users table only) and use it in pubkey_event_can_solve, flatten its nesting with early returns, drop the now-unused dispute_order_id parameter, and add regression tests for the read-only to write-capable takeover path.
Summary
Implement solver permission levels for disputes:
readsolvers can take disputes and communicate with usersread-writesolvers can additionally executeadmin-settleandadmin-cancelThis also upgrades
mostro-coreto0.8.4and usesCantDoReason::NotAuthorizedwhen an assigned read-only solver attempts a write action.Changes
Runtime
mostro-corefrom0.8.3to0.8.4admin_add_solver_actionnpub1...-> defaults to read-writenpub1...:read-> read-onlynpub1...:read-write-> read-writenpub1...:write-> alias for read-writeadmin-settlewithCantDoReason::NotAuthorizedwhen an assigned solver is read-onlyadmin-cancelwithCantDoReason::NotAuthorizedwhen an assigned solver is read-onlyDocumentation
docs/SOLVER_PERMISSION_LEVELS.mddocs/ADMIN_RPC_AND_DISPUTES.mddocs/ORDERS_AND_ACTIONS.mddocs/ARCHITECTURE.mddocs/README.mdNotes
This keeps the implementation simple and backward compatible:
users.categorycolumnadmin-add-solverpayloads without suffix still register full solversAddSolverRequestremains backward compatible and still defaults to read-write until extended laterValidation
cargo fmt✅cargo clippy --all-targets --all-features -- -D warnings✅cargo test✅211 tests passed, 0 failed✅Related:
mostro-core0.8.4Summary by CodeRabbit
New Features
Documentation
Dependencies
Migration note
migrations/20260417145500_solver_category_backfill.sql.sqlx::migrate!()), so operators do not need to updateusers.categorymanually when deploying a build that includes this PR.category = 2for existing rows whereis_solver = 1 AND category = 0, preserving historical settle/cancel authority for legacy solvers.admin-add-solverstill default toread-write(category = 2) unless:readis explicitly provided.