Serbero
Dispute coordination, notification, and assistance system for the Mostro ecosystem.
Serbero helps operators and users handle disputes more quickly, more consistently, and with better visibility — without expanding the system's fund-risk surface.
Table of Contents
- What It Does
- What It Does Not Do
- Architecture
- Implementation Status
- Install from Release
- Build from Source
- Configuration Reference
- How Serbero Behaves at Runtime
- Notification Format
- Observability and Audit Trail
- Degraded-Mode Behavior
- Project Layout
- Running the Test Suite
- Technical Constraints
- Project Principles
- Roadmap
- Release a New Version
- License
What It Does
Serbero sits alongside Mostro as a coordination layer that:
- Detects disputes by subscribing to Mostro's
kind 38386dispute events on Nostr relays. - Notifies solvers promptly via encrypted NIP-17 / NIP-59 gift-wrapped direct messages.
- Deduplicates across relay replays, reconnections, and process restarts using SQLite-backed persistence.
- Tracks lifecycle state (
new → notified → taken → waiting → escalated → resolved) and records every transition. - Re-notifies unattended disputes on a configurable timer and suppresses further notifications once a solver takes a dispute.
- Records an audit trail of every detection, notification attempt, state transition, and assignment event.
What It Does Not Do
Serbero never moves funds. It cannot sign admin-settle or admin-cancel, and it is never granted credentials that would allow it to do so. Dispute-closing authority belongs to Mostro and its human operators.
Mostro operates normally with or without Serbero. If Serbero is offline, operators continue resolving disputes manually as they always have.
Architecture
┌──────────────┐ kind 38386 events ┌────────────────────────────────┐
│ Mostro │ ──────────────────────────▶│ Serbero │
│ │ │ │
│ - Escrow │ │ Phase 1/2: │
│ - Settle │ NIP-59 gift wraps │ - Detection + dedup │
│ - Cancel │ ◀─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ - Solver notification │
│ - Perms │ (to solvers) │ - Lifecycle + assignment │
│ - Chat │ │ - Re-notification timer │
│ │ NIP-59 to shared keys │ │
│ │ ◀─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Phase 3 (mediation engine): │
│ │ (to dispute parties) │ - Take-flow + clarifying msg │
└──────┬───────┘ │ - Inbound ingest + dedup │
│ │ - Classification + policy │
│ │ - Summary or escalation │
│ │ - Handoff package (Phase 4) │
│ └─────────┬──────────────┬───────┘
│ NIP-44 chat │ │
│ (parties ↔ Serbero) HTTP /chat/completions │
│ (OpenAI-compatible) │
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌─────────────┐ ┌──────────┐
│ Buyer/Seller │ │ Reasoning │ │ SQLite │
│ (per-trade │ │ endpoint │ │ v3 │
│ shared keys) │ └─────────────┘ └──────────┘
└──────────────┘
- Mostro owns escrow state, permissions, and dispute-closing authority.
- Serbero owns notification, coordination, assignment visibility, audit logging, and (Phase 3) guided mediation.
- Reasoning backend (Phase 3): an OpenAI-compatible HTTP endpoint that Serbero calls for classification + summary drafting. Pluggable via config (
api_base+api_key_env); covers hosted OpenAI, self-hosted vLLM / llama.cpp / Ollama, LiteLLM, and any router proxy exposing/chat/completions. Outputs flow through a strict policy layer that suppresses fund-moving / dispute-closing instructions before any solver ever sees them.
Implementation Status
Serbero evolves in five phases. main currently implements
Phases 1, 2, and 3 end-to-end. Phase 3 ships the full guided-
mediation engine: take-flow, clarifying messages, inbound ingest,
classification, summary delivery, and escalation routing.
| Phase | Scope | Status on main |
|---|---|---|
| 1 | Always-on dispute listener and solver notification | Implemented |
| 2 | Intake tracking, assignment visibility, re-notification | Implemented |
| 3 | Guided mediation for low-risk disputes | Implemented (88 / 88 tasks): US1–US5 + foundational + polish all closed |
| 4 | Escalation support for write-permission operators | Planned (Phase 3 prepares the handoff package; Phase 4 will execute it) |
| 5 | Optional reasoning backend | OpenAI-compatible adapter shipped (covers hosted OpenAI, vLLM, llama.cpp, Ollama, LiteLLM, etc.); other vendor adapters are future work |
What Phase 3 ships
Setting [mediation].enabled = true and [reasoning].enabled = true
spawns the mediation engine task. On every tick it:
- Opens sessions for new disputes that pass the mediation-
eligibility gate. The reasoning provider classifies the dispute
first; only if the verdict is positive does Serbero issue
TakeDisputeand commit a session row (FR-122 / SC-110). The first clarifying message is dispatched to each party's shared (per-trade) pubkey — never their primary pubkey. If the reasoning verdict is negative (e.g. suspected fraud), no take is issued and the dispute is escalated with a dispute-scoped handoff to all configured solvers. - Ingests inbound replies (
fetch_inbound+ingest_inbound) with author authentication, dedup by(session_id, inner_event_id), transcript recomputation, and per-party last-seen tracking. - Classifies each turn through the configured reasoning provider
using the versioned prompt bundle. The policy layer enforces:
fraud / conflicting-claims flags escalate immediately,
confidence < thresholdescalates, fund-moving / dispute-closing outputs are suppressed and escalated asauthority_boundary_attempt. - Delivers cooperative summaries (
deliver_summary) to the assigned solver (or broadcasts to all configured solvers if none is assigned), then closes the session. - Escalates (
notify_solvers_escalation) on any of 12 triggers (conflicting_claims,fraud_indicator,low_confidence,party_unresponsive,round_limit,reasoning_unavailable,authorization_lost,authority_boundary_attempt,mediation_timeout,policy_bundle_missing,invalid_model_output,notification_failed) and writes a Phase 4 handoff package tomediation_events. - Resumes after restart:
startup_resume_passrebuilds the per-session ECDH key cache from themediation_sessionstable so inbound dedup and outbound key-derivation survive process restarts (FR-117). - Revalidates solver auth in a bounded background loop when the initial check fails — Phase 1/2 keeps running unaffected throughout.
Phase 1/2 behavior remains fully isolated: any Phase 3 bring-up failure (missing prompt bundle, unreachable reasoning provider, revoked solver auth) leaves Phase 1/2 detection + notification untouched.
Specifications
The Phase 1/2 specification lives in specs/002-phased-dispute-coordination/:
spec.md— user stories, requirements, acceptance criteriaplan.md— implementation plan, flow diagrams, degraded-mode tableresearch.md— pinned technical decisions (nostr-sdk, mostro-core, rusqlite)data-model.md— SQLite schema, state machine, Phase 3+ forward-looking sketchesquickstart.md— verification steps for Phases 1 and 2tasks.md— the 50-task implementation breakdown
Phase 3 specification:
spec.md— mediation user stories, requirements, acceptance criteria, and the normative sections on transport, reasoning, prompts, and memoryplan.md,research.md,data-model.md,contracts/— design artifactstasks.md— 88-task breakdown; see the per-task[X]markers for what has actually shipped onmaintoday
Install from Release
The fastest way to get Serbero is to download a pre-built binary.
|
The install script detects your OS and architecture, downloads the latest release, verifies the SHA-256 checksum of your specific asset (not with --ignore-missing, so a malformed release fails loudly), and installs the binary.
Install location: /usr/local/bin when that directory is writable by the running user, or when the script is running as root; otherwise ~/.local/bin (created if missing). If the chosen directory is not already on your PATH, the installer prints the exact export PATH="..." line to add to your shell profile. Set SERBERO_INSTALL_DIR before running to pick a different location.
Installer requirements: a POSIX shell (sh, dash, bash all work) plus curl or wget. No jq, no Python, no other runtimes. Checksum verification additionally needs sha256sum (Linux, GNU coreutils) or shasum -a 256 (macOS). If neither is present the installer prints a warning and continues without verifying.
Runtime requirements for the binary: none — not even Rust. The release binary is statically linked against musl (Linux) or the platform's system libraries (macOS / Windows) and is fully self-contained.
Before you run it, you will still need to have ready:
- A hex-encoded Nostr key pair for Serbero. You can generate one with rana or any Nostr key tool; Bech32 keys (
nsec...,npub...) must be converted to hex. The public key of this pair is the identity Serbero uses on Nostr — register it as a solver on the target Mostro instance before enabling Phase 3 (see Enable Phase 3). - The hex-encoded public key of the Mostro instance you want to monitor, plus hex public keys for every solver you want to notify.
- At least one Nostr relay URL that carries Mostro dispute events.
After installing, fetch the sample config and edit it. The binary-only install does not pull repository files, so download the sample directly from the repo:
# Edit config.toml with your keys, relays, and solvers
If you cloned the repository (e.g. for Build from Source below), cp config.sample.toml config.toml works too — the file is checked in at the repo root.
Manual download
If you prefer not to pipe to sh, download the binary for your platform from the Releases page, verify the checksum against checksums.sha256, make it executable, and place it somewhere on your PATH.
Available platforms
| Platform | Binary name |
|---|---|
| Linux x86_64 | serbero-linux-x86_64 |
| Linux ARM64 | serbero-linux-arm64 |
| macOS Intel | serbero-macos-x86_64 |
| macOS Apple Silicon | serbero-macos-arm64 |
| Windows x64 | serbero-windows-x86_64.exe |
Build from Source
If you prefer to compile Serbero yourself (e.g. for development, debugging, or running an unreleased commit):
Prerequisites (for building from source)
- Rust toolchain, stable, edition 2021. Install via rustup. Only needed if you compile from source. Users who install the pre-built binary do not need Rust.
Build
The binary is produced at ./target/release/serbero.
Configure
Create config.toml in the working directory. A reference template is provided at config.sample.toml — copy it and fill in your values (see Configuration Reference for the full surface):
[]
# Serbero's hex-encoded private key. Override via SERBERO_PRIVATE_KEY env var
# when running in production — do NOT commit this file with a real key.
= "<hex-encoded private key>"
= "serbero.db"
= "info"
[]
# Hex-encoded public key of the Mostro instance to monitor.
= "<hex-encoded public key>"
[[]]
= "wss://relay.example.com"
[[]]
= "<hex-encoded solver public key>"
= "read" # "read" or "write" — see notes below
[[]]
= "<hex-encoded solver public key>"
= "write"
[]
= 300 # re-notify disputes unattended this long
= 60 # how often to scan for unattended disputes
About the permission field: Phase 1 and Phase 2 notify every configured solver regardless of this value. Permission is parsed, stored, and surfaced to later phases — Phase 4 (escalation routing) will target write-permission solvers specifically. Setting it today is future-proofing, not gating.
Run
Serbero reads config.toml from the current working directory. Secrets and a few operational parameters can be overridden via environment variables:
# Minimal invocation — expects ./config.toml
# Override the private key via env (recommended for production)
SERBERO_PRIVATE_KEY="<hex-encoded private key>"
# Point at a different config file (any path)
SERBERO_CONFIG=/etc/serbero/config.toml
# Verbose tracing (module-level filters also supported)
SERBERO_LOG=debug
SERBERO_LOG="serbero=debug,nostr_sdk=info"
Shut down with Ctrl-C (SIGINT). On Unix hosts Serbero also catches SIGTERM (so systemctl stop, kill, and container shutdowns work). Both paths abort the re-notification timer and exit cleanly.
Verify Phase 1
- Start Serbero with a valid config pointing at a test relay.
- Publish a
kind 38386event with tagss=initiated,z=dispute,y=<mostro_pubkey>,d=<dispute_id>, andinitiator=buyer(orseller). - Every configured solver should receive an encrypted gift-wrap DM within seconds containing the dispute ID, initiator role, and event timestamp.
- Publish the same event again — no duplicate notification should be sent.
- Restart Serbero pointed at the same
db_path— previously-seen disputes should not be re-notified.
Verify Phase 2
- After the initial notification, wait for
renotification_secondsto elapse. Solvers should receive a single re-notification withnotif_type='re-notification'and a status-aware payload. - Publish an
s=in-progressevent for the same dispute (this simulates a solver taking it via Mostro). - Serbero transitions the dispute to
taken, records theassigned_solverfrom the event'sptag if present, and sends an assignment notification to all solvers. - No further re-notifications are sent for that dispute.
Enable Phase 3 (guided mediation)
Phase 3 layers on top of Phases 1 and 2. To enable it:
-
Register Serbero as a solver on the target Mostro instance with at least
readpermission. Serbero's public key is derived from theprivate_keyfield in[serbero]— you can obtain it with any Nostr key tool (e.g.nak key public <hex-secret-key>). In Mostrix, go to Settings → Solvers, paste the hex pubkey, and selectreadpermission. Serbero never holds fund-moving credentials. -
Provision a reasoning endpoint (any OpenAI-compatible HTTPS endpoint: hosted OpenAI, self-hosted vLLM / llama.cpp / Ollama, LiteLLM, or any router proxy exposing
/chat/completions). -
Export the API key under the env-var name configured in
[reasoning].api_key_env(default:SERBERO_REASONING_API_KEY): -
Add the Phase 3 sections to
config.toml(see Phase 3 configuration surface) and ensure theprompts/phase3-*.mdfiles exist and contain real mediation content (the repo ships a working bundle — see Prompt bundle). -
Restart:
At startup you should see (alongside the Phase 1/2 lines):
loaded config mostro_pubkey=<hex> db_path=serbero.db relay_count=N solver_count=M ... Phase 3 prompt bundle loaded prompt_bundle_id=phase3-default policy_hash=<hex> reasoning provider health check ok Phase 3 mediation is fully configured; engine task will be spawned
If the reasoning health check fails, Phase 3 stays disabled for the run (SC-105) and Phase 1/2 continues unaffected:
Phase 3 reasoning health check failed; mediation disabled for this run
(Phase 1/2 detection and notification continue unaffected)
If the initial solver-auth check fails, Phase 3 refuses to open new sessions and a bounded retry loop runs in the background; warnings log per attempt.
Verify Phase 3
- Cooperative path (US3) — publish a buyer-initiated dispute that the policy layer can classify as
coordination_failure_resolvable(e.g., a payment-timing case). Expected:- A
mediation_sessionsrow withstate='awaiting_response'and the policy hash pinned. - The buyer's and seller's shared (per-trade) pubkeys receive the first clarifying gift wrap (NOT their primary pubkeys — SC-107).
- After both parties reply, a
mediation_summariesrow is written and the assigned solver receives amediation_summarynotification. The session transitionssummary_pending → summary_delivered → closed.
- A
- Escalation path (US4) — drive any of the 12 triggers (let
party_response_timeout_secondselapse without replies, exceedmax_rounds, or take the reasoning provider offline). Expected:- Session transitions to
escalation_recommended. - A
mediation_eventsrow records the trigger and ahandoff_preparedrow carries the Phase 4 package. - The configured solvers receive a
mediation_escalation_recommendednotification ("Needs human judgment").
- Session transitions to
- Provider swap (US5) — stop the daemon, change
[reasoning].provider/model/api_base/api_key_envto point at a different OpenAI-compatible endpoint, export the new key, and restart. New sessions call the new endpoint; no rebuild needed. - Restart resume (FR-117) — kill the daemon mid-session and restart. The startup-resume pass rebuilds the per-session key cache from the database, so inbound replies are deduped correctly and outbound responses go to the right shared keys.
For the full operator walkthrough see specs/003-guided-mediation/quickstart.md.
Configuration Reference
config.toml structure
| Section | Key | Type | Required | Notes |
|---|---|---|---|---|
[serbero] |
private_key |
string | ✓ | Hex-encoded secret key. Override: SERBERO_PRIVATE_KEY. |
[serbero] |
db_path |
string | Defaults to serbero.db. Override: SERBERO_DB_PATH. |
|
[serbero] |
log_level |
string | trace / debug / info / warn / error. Defaults to info. Override: SERBERO_LOG. |
|
[mostro] |
pubkey |
string | ✓ | Hex-encoded public key of the Mostro instance to monitor. |
[[relays]] |
url |
string | ≥ 1 | One or more wss://… relay URLs. Serbero connects to all of them. |
[[solvers]] |
pubkey |
string | Hex-encoded solver public key. | |
[[solvers]] |
permission |
string | "read" or "write". Not used for filtering in Phases 1–2; reserved for Phase 4 routing. |
|
[timeouts] |
renotification_seconds |
integer | Defaults to 300. Disputes in notified state older than this are re-notified. |
|
[timeouts] |
renotification_check_interval_seconds |
integer | Defaults to 60. How often the re-notification timer scans the DB. |
Environment variable overrides
| Variable | Overrides | Behavior |
|---|---|---|
SERBERO_CONFIG |
path of config file | Defaults to ./config.toml. |
SERBERO_PRIVATE_KEY |
[serbero].private_key |
Preferred way to inject the key in production / systemd / containers. |
SERBERO_DB_PATH |
[serbero].db_path |
Absolute or relative path. |
SERBERO_LOG |
[serbero].log_level |
Accepts either a level (info) or a tracing-subscriber filter string. |
Empty or whitespace-only env values are ignored — an accidentally-unset shell variable will not wipe a valid config entry.
No CLI flag surface
Phases 1 and 2 intentionally do not commit to a CLI flag surface. The entire configuration lives in config.toml plus the environment variables above. If you need to point at a different config file, use SERBERO_CONFIG, not a flag.
Phase 3 configuration surface
Phase 3 adds four new functional sections. They are all #[serde(default)] — if you omit them, the daemon behaves as a Phase 1/2 daemon. With both [mediation].enabled = true and [reasoning].enabled = true, the daemon runs the Phase 3 bring-up (prompt-bundle load, reasoning health check) and spawns the mediation engine task.
[]
= true # Phase 3 mediation feature flag (see caveat above)
= 2
= 1800
# Solver-auth bounded revalidation loop (scope-controlled)
= 60
= 3600
= 86400
= 24
[]
= true
= "openai" # only shipped adapter
= "gpt-5" # anything the endpoint supports
= "https://api.openai.com/v1" # swap for any OpenAI-compatible endpoint
= "SERBERO_REASONING_API_KEY" # vendor-neutral on purpose
= 30
= 1 # adapter owns the HTTP retry budget
[]
= "./prompts/phase3-system.md"
= "./prompts/phase3-classification.md"
= "./prompts/phase3-escalation-policy.md"
= "./prompts/phase3-mediation-style.md"
= "./prompts/phase3-message-templates.md"
[]
= 10
Per-field notes:
| Section | Key | Notes |
|---|---|---|
[mediation] |
enabled |
Master switch for Phase 3. false → daemon behaves as a pure Phase 1/2 daemon. |
[mediation] |
max_rounds |
Number of outbound+inbound pairs per session before round_limit escalation. Defaults to 2. |
[mediation] |
party_response_timeout_seconds |
Triggers party_unresponsive escalation. Defaults to 1800 (30 min). Set to 0 to disable the timer. |
[mediation] |
solver_auth_retry_* |
Bounded revalidation loop for Serbero's solver registration in Mostro. Defaults: 60s→3600s, 24h/24 caps. |
[reasoning] |
enabled |
Must be true (alongside [mediation].enabled) for the engine task to spawn. |
[reasoning] |
provider |
openai (also covers OpenAI-compatible endpoints — vLLM, llama.cpp, Ollama, LiteLLM, any router proxy). anthropic / ppqai / openclaw are placeholders that fail loudly at startup. |
[reasoning] |
model |
Whatever model the configured endpoint accepts (e.g., gpt-5, gpt-4o-mini, a self-hosted model name). |
[reasoning] |
api_base |
Where the HTTP client points. Change this to swap to any OpenAI-compatible endpoint without a rebuild. |
[reasoning] |
api_key_env |
Environment variable name whose value holds the credential. Defaults to SERBERO_REASONING_API_KEY. The variable name is just configuration — point it at any var your secrets pipeline already sets. |
[reasoning] |
request_timeout_seconds |
Per-HTTP-call timeout. Floored to ≥ 1 s. |
[reasoning] |
followup_retry_count |
Adapter-owned bounded retry budget (FR-104). Additional attempts after the initial request on retryable errors. 0 = no retry. |
[prompts] |
*_path |
Paths to the versioned prompt bundle files. The default paths match the prompts/ tree in this repo. |
[chat] |
inbound_fetch_interval_seconds |
Mostro-chat inbound polling cadence used by the engine ingest loop. |
Secrets and environment variable resolution
config.tomlnever carries secrets. The[reasoning].api_keyfield isskip_deserializing; TOML cannot set it.- At startup the daemon reads the env variable named by
[reasoning].api_key_env(default:SERBERO_REASONING_API_KEY) and stores the trimmed value. Surrounding whitespace or trailing newlines are stripped so nothing breaks bearer-token auth. - If
[reasoning].enabled = trueand the named variable is unset or empty, the daemon returns a loudError::Configand Phase 3 stays off. Phase 1/2 behavior is unaffected. - Choose a variable name that fits your secrets pipeline. The default is vendor-neutral so a freshly-cloned daemon does not imply "OpenAI-only"; point it at whatever variable your deployment environment is already exporting.
Prompt bundle
The default layout — matched by [prompts].* defaults — is:
prompts/
├── phase3-system.md # mediation identity + authority limits + honesty discipline
├── phase3-classification.md # 5 labels (snake_case canonical), 5 flags, confidence semantics
├── phase3-escalation-policy.md # 12 triggers + handoff package shape
├── phase3-mediation-style.md # tone, prohibited / preferred phrasings
└── phase3-message-templates.md # first / follow-up / summary / escalation / timeout templates
The shipped bundle is real, working content matching spec.md §AI Agent Behavior Boundaries — assistance-only identity, no fund-moving authority, explicit honesty / uncertainty rules, allowed vs. disallowed outputs. Operators can amend it (e.g., to localize message templates) without code changes; the policy_hash regenerates deterministically at startup.
Every mediation session pins the bundle's policy_hash and prompt_bundle_id (SC-103). Every audit row in mediation_sessions, mediation_messages, mediation_summaries, mediation_events, and reasoning_rationales carries the same pair, so behavior is reproducible from git history and the audit trail can be replayed against the exact bundle bytes that produced it.
Missing files → Error::PromptBundleLoad; Phase 3 stays off, Phase 1/2 keeps running.
How Serbero Behaves at Runtime
Startup
- Load config from
$SERBERO_CONFIG(or./config.toml) and apply env overrides. - Initialize
tracing-subscriberusingSERBERO_LOGorlog_levelfrom the config. - Open the SQLite database at
db_path; run migrations (schema_versionis tracked so this is idempotent and survives restarts). - Build the Nostr client from the private key and connect to every configured relay. nostr-sdk handles automatic reconnection with backoff.
- Subscribe to
kind 38386events for the configured Mostro pubkey withs ∈ {initiated, in-progress},z=dispute,y=<mostro_pubkey>. - Spawn the re-notification timer task.
- Enter the main notification-handling loop, dispatching each incoming event by its
stag.
New dispute (s=initiated)
- Extract
dispute_id(fromdtag),initiator(buyer or seller),mostro_pubkey(fromy), and the event'sid/created_at. - Attempt to
INSERTintodisputes(keyed bydispute_idwithON CONFLICT DO NOTHING).- Duplicate → log at debug, skip notification (idempotent replay / restart).
- Insert fails → log an error and do not notify. This is a deliberate Phase 1 policy: the dispute may not be notified unless the same event is observed again after persistence recovers. See
plan.md§Deduplication Strategy andspec.mdclarification 3. - Inserted → proceed.
- For each configured solver: parse pubkey → send NIP-17/NIP-59 gift-wrapped DM via
send_private_msg→ record the attempt (sentorfailed, with the error message) in thenotificationstable. - If at least one notification was sent, transition the dispute
new → notified, record the transition indispute_state_transitions, and updatelast_notified_at.
Dispute taken (s=in-progress)
- Look up the dispute by
dispute_id. - If the dispute is already in
taken/waiting/escalated/resolved, treat as idempotent no-op. - Otherwise transition
→ taken, record the solver pubkey from the event'sptag (if present) inassigned_solver, and record the state transition. - Send an assignment notification (
notif_type='assignment') to every configured solver.
Re-notification timer
Every renotification_check_interval_seconds, the background task:
- Computes
cutoff = now - renotification_seconds. - Queries disputes with
lifecycle_state = 'notified' AND last_notified_at < cutoff. - For each match: sends a re-notification (
notif_type='re-notification') including the currentlifecycle_stateand elapsed time, then bumpslast_notified_atto prevent the same tick from double-firing.
Disputes that are already taken, waiting, escalated, or resolved never trigger re-notifications — the SQL filter enforces this.
Phase 3 mediation engine
When [mediation].enabled = true and the bring-up succeeds, an engine task runs alongside the Phase 1/2 loop. Each tick:
- Open — for any new dispute that passes the eligibility gate, run the dispute-chat take-flow against Mostro, derive per-trade ECDH shared keys for both parties, persist a
mediation_sessionsrow with the bundle pinned, and dispatch the first clarifying message to each party's shared pubkey (SC-107). Outbound rows land inmediation_messageswith provenance. - Ingest —
fetch_inboundpolls Mostro's chat surface everyinbound_fetch_interval_seconds.ingest_inboundauthenticates the inner event's author against the expected trade pubkey, pins the inner kind toTextNote, dedups by(session_id, inner_event_id)(so a relay replay or daemon restart never double-counts), recomputesround_countfrom the transcript, and updates per-party last-seen timestamps. - Classify — once both parties have replied for the round, the engine calls the reasoning provider's
classifymethod with the full prompt bundle (so thepolicy_hashpin is honest) and the transcript. The response is parsed into a snake_case-keyed JSON shape (coordination_failure_resolvable,conflicting_claims,suspected_fraud,unclear,not_suitable_for_mediation) plus a confidence score and flags. - Apply policy — fraud / conflicting-claims flags escalate immediately;
confidence < 0.5escalates withlow_confidence;Summarizepaired with a non-cooperative label escalates withinvalid_model_output; any output containing fund-moving / dispute-closing phrases is suppressed and escalated withauthority_boundary_attempt. Otherwise the policy decides betweenAskClarification,Summarize, orEscalate(reason). - Summarize or escalate — the cooperative path calls
summarize, persistsmediation_summaries, and routes amediation_summarynotification to the assigned solver (or broadcasts to all configured solvers if none is assigned). The session transitionssummary_pending → summary_delivered → closed. The escalation path writes ahandoff_preparedrow with the Phase 4 package and sends amediation_escalation_recommendednotification. - Resume after restart —
startup_resume_passrebuilds the per-session ECDH key cache frommediation_sessionsso a daemon restart never breaks dedup or outbound key derivation (FR-117). Sessions whosepolicy_hashno longer matches the loaded bundle are escalated withpolicy_bundle_missing. - Auth retry — if the initial solver-auth check fails, a bounded background loop revalidates with exponential backoff (knobs under
[mediation].solver_auth_retry_*). Until it recovers, new session opens are deterministically refused; Phase 1/2 continues unaffected.
All rationale text is written only to the audit store (reasoning_rationales) and referenced by rationale_id (SHA-256 content hash) in general logs and mediation_events.payload_json (FR-120). The RationaleText::Debug impl redacts the body to <N bytes redacted>.
Notification Format
All notifications are NIP-17/NIP-59 gift-wrapped direct messages. The rumor content is plain UTF-8 text. Phase 1 and 2 use three notification types:
Initial notification
New Mostro dispute requires attention.
dispute_id: <dispute-id>
initiator: <buyer|seller>
event_timestamp: <unix-seconds>
status: initiated
Re-notification
Mostro dispute is still unattended.
dispute_id: <dispute-id>
lifecycle_state: notified
time_elapsed_seconds: <n>
Assignment notification
Mostro dispute has been taken.
dispute_id: <dispute-id>
assigned_solver: <pubkey|unknown>
lifecycle_state: taken
Mediation summary (Phase 3, cooperative path)
notif_type='mediation_summary', gift-wrapped to the assigned solver (targeted) or every configured solver (broadcast):
<summary text from the reasoning provider>
Suggested next step: <single-line recommendation>
The summary text describes what each party reported and proposes a cooperative resolution. The "Suggested next step" line is advisory only — no fund-moving instructions are ever drafted (the policy layer suppresses them). The full rationale is preserved in reasoning_rationales and referenced by rationale_id in mediation_events.
Mediation escalation (Phase 3, US4)
notif_type='mediation_escalation_recommended', gift-wrapped to the assigned solver or all configured solvers:
Mediation session <session_id> (dispute <dispute_id>) escalated —
trigger: <snake_case_trigger>. Needs human judgment.
The compact body keeps DMs readable across Nostr clients; the full handoff package (evidence refs, rationale refs, prompt bundle id, policy hash, assembled-at timestamp) lives alongside in mediation_events as a handoff_prepared row for Phase 4 to consume.
Dispute-scoped escalation (FR-122, pre-take)
notif_type='mediation_escalation_recommended', broadcast to all configured solvers (no session was opened, so there is no assigned solver):
Dispute <dispute_id> escalated before mediation take —
trigger: <snake_case_trigger>. Serbero ran the reasoning verdict and
the policy layer said this dispute is not a mediation candidate.
No session was opened. Needs human judgment.
This fires when the reasoning verdict at session-open time is negative (e.g. suspected_fraud or not_suitable_for_mediation). No TakeDispute is issued and no mediation_sessions row is committed (SC-110).
Final resolution report (FR-124)
notif_type='mediation_resolution_report', broadcast to all configured solvers:
Final resolution report for dispute <dispute_id>.
resolution: <settled|cancelled|...>
escalation_count: <N>
rounds: <N>
duration_seconds: <N>
handoff: <true|false>
Emitted once when a dispute that had any Phase 3 mediation context (session rows, dispute-scoped handoff events, or mediation messages) transitions to a resolved terminal state. Idempotent: duplicate dispute_resolved events do not trigger additional reports. Contains no rationale text (FR-120).
Notifications never include the initiator's primary pubkey — only their trade role (buyer / seller). Outbound mediation gift wraps address parties' shared (per-trade) pubkeys, never their primary pubkeys (SC-107). This matches the privacy clarification in spec.md Session 2026-04-16.
Observability and Audit Trail
Serbero emits structured tracing spans and events at every decision point:
detected/duplicate_skip/persistence_failednotification_sent/notification_failed(withsolverand error)state_transition(withfrom,to,trigger)assignment_detected(withassigned_solver)assignment_notification_sent/assignment_notification_failedrenotification_tick(withcount)start_attempt_started/start_attempt_stopped(withtrigger,stop_reason)reasoning_verdict/reasoning_verdict_negativetake_dispute_issued(withoutcome: success|failure)solver_dispute_escalation_notified(FR-122 dispute-scoped handoff)solver_final_resolution_report_sent(FR-124)
Use SERBERO_LOG to tune the filter:
SERBERO_LOG="serbero=debug,nostr_sdk=warn"
SQLite tables
Every audit-relevant fact is also in the database, so you can reconstruct the history of a dispute without grepping logs.
Phase 1/2 tables:
disputes— one row per detected dispute, includinglifecycle_state,assigned_solver,last_notified_at,last_state_change.notifications— one row per notification attempt (initial,re-notification,assignment,mediation_summary,mediation_escalation_recommended), withstatus(sent/failed) anderror_message.dispute_state_transitions— every state change withfrom_state,to_state,transitioned_at,trigger.schema_version— tracks applied migrations; migrations are idempotent and wrapped in per-version transactions.
Phase 3 tables (migration v3):
mediation_sessions— one row per opened session:state,round_count, the pinnedprompt_bundle_id+policy_hash,buyer_shared_pubkey/seller_shared_pubkey, per-party last-seen timestamps.mediation_messages— every outbound and inbound message, dedup-keyed by(session_id, inner_event_id), with the bundle pinned on outbound rows.reasoning_rationales— content-addressed (SHA-256) rationale text from every classify / summarize call, with provider, model, and bundle pinned. Operator-only audit store; FR-120 ensures the body never leaks into general logs ormediation_events.payload_json.mediation_summaries— one row per cooperative summary delivered, with classification, confidence, summary text, suggested next step, and the rationale reference id.mediation_events— every lifecycle / audit event (session-open, classification, summary-generated, escalation-triggered, handoff-prepared, auth-retry-{attempt,recovered,terminated}, etc.) with the bundle and (where applicable) rationale referenced by id.
Inspect with the usual sqlite3 CLI:
# Phase 1/2 — recent disputes + notifications
# Phase 3 — mediation sessions and their state
# Phase 3 — full transcript for a session
# Phase 3 — lifecycle / escalation events
# Phase 3 — rationale audit store (operator-only; gate behind filesystem permissions)
SC-102 audit (Phase 3 never executed a fund-moving action)
Re-confirm at any time that no Mostro admin action ever flowed through Serbero:
# Expected: 0
Combined with the constitutional invariant that Serbero holds no credentials for those actions, Phase 3 satisfies I. Fund Isolation First.
Degraded-Mode Behavior
| Failure | Behavior |
|---|---|
| Single relay drops | nostr-sdk auto-reconnects with backoff. Other relays continue serving events. |
| All relays drop | Reconnection keeps retrying. Notifications halt until a relay comes back. The daemon keeps running. |
| SQLite read failure | Notifications halt. The daemon logs the error, keeps retrying DB access, and resumes notifications when persistence recovers. Deduplication integrity is prioritized over delivery. |
| SQLite write failure on INSERT | No Phase 1 queue exists. The dispute may not be notified at all unless the same event is observed again after persistence recovers (e.g., a relay retransmission or operator replay). |
| Notification send failure | Logged as a failed row in notifications with the error message. Phase 1 does not retry individual sends. Phase 2's re-notification timer covers disputes that stay unattended. |
| Invalid solver pubkey in config | Logged as a failed notification row; other solvers still receive the notification. The daemon keeps running. |
| No solvers configured | Logged as a WARN at startup. Serbero still detects and persists disputes, but the notification loop is skipped — the audit trail is preserved. |
| Serbero fully offline | Mostro operates normally. Solvers resolve disputes manually. When Serbero comes back and reconnects, it resumes detecting new events. Historic events delivered while offline are the relay's to replay. |
| Phase 3 — prompt bundle missing / unloadable | Error::PromptBundleLoad at startup; Phase 3 stays disabled for the run. Phase 1/2 detection + notification continue unaffected. Resumed sessions whose pinned policy_hash no longer matches the loaded bundle are escalated with policy_bundle_missing. |
| Phase 3 — reasoning provider health-check fails | SC-105: Phase 3 stays disabled for the run; engine task is not spawned; Phase 1/2 continues unaffected. Operator-actionable error logs provider, model, api_base, and the underlying error. |
| Phase 3 — reasoning provider unreachable mid-session | The classify / summarize call surfaces ReasoningError; the session escalates with reasoning_unavailable. Adapter-owned bounded retry budget (followup_retry_count) covers transient errors first. |
| Phase 3 — reasoning provider returns garbage | MalformedResponse → escalates with reasoning_unavailable. Structurally inconsistent shape (e.g. Summarize + non-cooperative label) escalates with invalid_model_output. |
| Phase 3 — reasoning output crosses authority boundary | Suppressed by AUTHORITY_BOUNDARY_PHRASES detection; session escalates with authority_boundary_attempt. The full output is preserved in the rationale store; general logs reference it by id only. |
| Phase 3 — solver auth lost at startup | Initial check fails → bounded auth-retry loop runs in the background with exponential backoff; session opens are deterministically refused until recovery. Phase 1/2 unaffected. |
| Phase 3 — solver auth revoked mid-session | Outbound auth failure surfaces as AuthorizationLost; affected session escalates with authorization_lost. Auth-retry loop resumes. |
| Phase 3 — party stops responding | After party_response_timeout_seconds, session escalates with party_unresponsive. Set the timeout to 0 to disable the check (test / staging only). |
| Phase 3 — round limit reached | After max_rounds outbound+inbound pairs without convergence, session escalates with round_limit. |
| Phase 3 — daemon restart mid-session | startup_resume_pass rebuilds the per-session ECDH key cache from mediation_sessions; inbound dedup and outbound key derivation survive intact (FR-117). The restart-dedup integration test pins this. |
| Phase 3 — mediation summary undeliverable | If the summary persists but every solver send fails (or no recipients are configured), session escalates with notification_failed so the audit trail surfaces it instead of stranding it at summary_pending. |
Project Layout
.
├── Cargo.toml, Cargo.lock
├── clippy.toml, rustfmt.toml
├── config.toml (you create this; gitignored)
├── prompts/ # Phase 3 versioned prompt bundle
│ ├── phase3-system.md
│ ├── phase3-classification.md
│ ├── phase3-escalation-policy.md
│ ├── phase3-mediation-style.md
│ └── phase3-message-templates.md
├── src/
│ ├── main.rs # binary entry point
│ ├── lib.rs # re-exports modules for tests
│ ├── error.rs # Error + Result types
│ ├── config.rs # TOML + env loader
│ ├── daemon.rs # main loop + re-notification + Phase 3 bring-up
│ ├── dispatcher.rs # event routing by `s` tag
│ ├── nostr/ # Client, subscriptions, gift-wrap notifier
│ ├── handlers/ # s=initiated / s=in-progress handlers
│ ├── chat/ # Phase 3 dispute-chat surface
│ │ ├── dispute_chat_flow.rs # take-flow against Mostro
│ │ ├── inbound.rs # fetch + ingest with author auth + dedup
│ │ ├── outbound.rs # build wraps for shared pubkeys
│ │ └── shared_key.rs # ECDH per-trade key derivation
│ ├── reasoning/ # Phase 3 provider abstraction
│ │ ├── mod.rs # ReasoningProvider trait + factory
│ │ ├── openai.rs # OpenAI-compatible adapter
│ │ ├── not_yet_implemented.rs # NYI guard for unshipped vendor names
│ │ └── health.rs # startup health check (SC-105)
│ ├── prompts/ # Phase 3 prompt bundle loader + hash
│ │ ├── mod.rs # PromptBundle + load_bundle
│ │ └── hash.rs # deterministic SHA-256 of bundle bytes
│ ├── mediation/ # Phase 3 engine
│ │ ├── mod.rs # run_engine, draft_and_send_initial_message,
│ │ │ # deliver_summary, notify_solvers_escalation,
│ │ │ # startup_resume_pass
│ │ ├── session.rs # open_session + auth gate
│ │ ├── start.rs # try_start_for: unified entry for event-driven + tick
│ │ ├── auth_retry.rs # bounded solver-auth revalidation
│ │ ├── policy.rs # classification → action decision
│ │ ├── eligibility.rs # composed eligibility predicate (FR-123)
│ │ ├── follow_up.rs # mid-session ingest + classify loop
│ │ ├── transcript.rs # transcript builder for reasoning calls
│ │ ├── report.rs # FR-124 final solver-facing resolution report
│ │ ├── summarizer.rs # summarize + AUTHORITY_BOUNDARY_PHRASES
│ │ ├── router.rs # targeted vs broadcast solver routing
│ │ └── escalation.rs # 12 triggers + handoff package
│ ├── db/
│ │ ├── mod.rs # connection + pragmas
│ │ ├── migrations.rs # schema_version (v1, v2, v3) + per-version txns
│ │ ├── disputes.rs # insert, get, lifecycle state helpers
│ │ ├── notifications.rs # record_notification{,_logged}
│ │ ├── state_transitions.rs # unattended dispute query
│ │ ├── mediation.rs # mediation_sessions + mediation_messages
│ │ ├── mediation_events.rs # lifecycle / audit events
│ │ └── rationales.rs # content-addressed rationale audit store
│ └── models/
│ ├── config.rs # typed config structs (incl. Phase 3 sections)
│ ├── dispute.rs # Dispute + LifecycleState state machine
│ ├── notification.rs # NotificationStatus / NotificationType
│ ├── mediation.rs # ClassificationLabel, Flag, EscalationTrigger, …
│ └── reasoning.rs # ReasoningRequest / Response + RationaleText
├── tests/
│ ├── common/mod.rs # MockRelay harness + SolverListener
│ ├── phase1_detection.rs ├── phase2_lifecycle.rs
│ ├── phase1_dedup.rs ├── phase2_assignment.rs
│ ├── phase1_failure.rs ├── phase2_renotification.rs
│ ├── phase3_session_open.rs ├── phase3_summary_escalation.rs
│ ├── phase3_session_open_gating.rs ├── phase3_authority_boundary.rs
│ ├── phase3_response_ingest.rs ├── phase3_escalation_triggers.rs
│ ├── phase3_response_dedup_restart.rs ├── phase3_provider_swap.rs
│ ├── phase3_stale_message.rs ├── phase3_provider_not_yet_implemented.rs
│ ├── phase3_routing_model.rs ├── phase3_cooperative_summary.rs
│ ├── phase3_event_driven_start.rs ├── phase3_superseded_by_human.rs
│ ├── phase3_take_reasoning_coupling.rs├── phase3_external_resolution_report.rs
│ ├── phase3_followup_round.rs ├── phase3_followup_summary.rs
│ ├── phase3_followup_reasoning_failure.rs
│ └── fixtures/prompts/ # stable bundle for tests (untouched)
└── specs/
├── 002-phased-dispute-coordination/ # Phase 1/2 spec + plan + tasks
└── 003-guided-mediation/ # Phase 3 spec + plan + tasks + contracts + quickstart
Running the Test Suite
Note: Tests require the Rust toolchain. If you installed Serbero via the release binary and want to run tests, clone the repo and build from source.
The crate ships 228 tests: 179 inline #[cfg(test)] lib unit tests (covering parsers, policy decisions, audit-store invariants, migrations, prompt loading, …) plus 49 integration tests that spin up an in-process nostr-relay-builder::MockRelay (and, where relevant, an httpmock reasoning endpoint) and exercise the daemon end-to-end.
# Unit tests only (fast)
# Full suite (unit + integration)
# Lint + format checks
# Release build
Phase 1/2 integration tests cover the scenarios from specs/002-phased-dispute-coordination/quickstart.md:
| Test file | What it verifies |
|---|---|
phase1_detection.rs |
New dispute detected → every solver receives correct gift-wrapped DM |
phase1_dedup.rs |
Duplicate events and daemon restarts produce exactly one notification |
phase1_failure.rs |
Invalid solver pubkey → failed row recorded, other solvers still notified; no-solvers path persists without notifying |
phase2_lifecycle.rs |
new → notified → taken transition chain recorded in correct order |
phase2_assignment.rs |
s=in-progress → lifecycle_state taken, assigned_solver set, assignment notification delivered, no further re-notifications |
phase2_renotification.rs |
Unattended disputes re-notified past timeout; taken disputes are not |
Phase 3 integration tests cover the scenarios from specs/003-guided-mediation/quickstart.md:
| Test file | What it verifies |
|---|---|
phase3_session_open.rs |
Open session → take-flow → first clarifying message dispatched to both parties' shared pubkeys |
phase3_session_open_gating.rs |
Reasoning health-check failure → session open refused; Phase 1/2 still notifies (SC-105) |
phase3_response_ingest.rs |
Inbound replies authenticated, dedup-keyed by inner event id, transcript + last-seen updated |
phase3_response_dedup_restart.rs |
Inbound dedup survives daemon restart (FR-117 — startup_resume_pass rebuilds the session-key cache) |
phase3_stale_message.rs |
Stale inbound is persisted but does not advance the session |
phase3_routing_model.rs |
Targeted (assigned solver) vs broadcast routing; fallback when assigned solver is unknown |
phase3_cooperative_summary.rs |
US3 happy path: summary persisted, session closes, assigned solver notified, FR-120 redaction + SC-103 audit consistency pinned |
phase3_summary_escalation.rs |
Empty recipient list → notification_failed escalation (no stranded summaries) |
phase3_authority_boundary.rs |
Fund-moving / dispute-closing output suppressed; session escalates with authority_boundary_attempt |
phase3_escalation_triggers.rs |
All applicable triggers fire correctly: conflicting_claims, fraud_indicator, low_confidence, party_unresponsive, round_limit, reasoning_unavailable, authorization_lost |
phase3_provider_swap.rs |
Two OpenAiProviders pointing at distinct httpmock endpoints both work; openai-compatible routes to the same adapter (US5) |
phase3_event_driven_start.rs |
SC-109: event-driven path opens session without the background tick running |
phase3_superseded_by_human.rs |
External resolution (human solver) closes session + fires FR-124 final report to all solvers |
phase3_take_reasoning_coupling.rs |
FR-122 / SC-110: negative reasoning verdict (fraud flag or model escalate) skips TakeDispute entirely |
phase3_external_resolution_report.rs |
FR-124: final solver-facing report emitted for every dispute with Phase 3 context; idempotent on re-fire |
phase3_followup_round.rs |
SC-112: mid-session happy path — party replies trigger second outbound within one ingest tick |
phase3_followup_summary.rs |
SC-114: mid-session summarize branch fires exactly once and closes session |
phase3_followup_reasoning_failure.rs |
SC-115: three consecutive reasoning failures escalate with reasoning_unavailable |
phase3_provider_not_yet_implemented.rs |
Unshipped vendor names (anthropic, ppqai, openclaw) fail loudly at startup with an actionable error |
Technical Constraints
- Rust, stable, edition 2021.
- nostr-sdk v0.44.1 for all Nostr communication (subscriptions, event handling, NIP-17 / NIP-59 gift-wrap messaging). The
nip59,nip44, andnip04features are enabled. - mostro-core v0.8.4 for protocol types (
NOSTR_DISPUTE_EVENT_KIND, disputeStatusenum,Actionvariants). - rusqlite 0.31 with the
bundledfeature — no external SQLite install required. No ORM, no storage abstraction layer. - tokio 1 runtime (required by nostr-sdk),
tracingfor structured logs,toml+serdefor configuration. - Prefers Nostr-native communication (encrypted gift wraps) over external bridges or dashboards.
Project Principles
Serbero is governed by a constitution that defines non-negotiable rules. The key principles:
- Fund Isolation First — never touch funds or sign dispute-closing actions.
- Protocol-Enforced Security — safety boundaries enforced by Mostro, not by prompts or model behavior.
- Human Final Authority — complex, adversarial, or ambiguous disputes always go to a human operator.
- Operator Notification as Core — detecting and notifying operators is a primary responsibility.
- Assistance Without Authority — assist and guide, never impose outcomes.
- Auditability by Design — every action, classification, and state transition is logged.
- Graceful Degradation — Mostro works fine without Serbero.
- Privacy by Default — minimum necessary information to each participant.
- Nostr-Native Coordination — encrypted messaging first, external integrations second.
- Portable Reasoning Backends — no lock-in to any single AI provider or runtime.
- Incremental Scope — evolve in stages through explicit specifications.
- Honest System Behavior — surface uncertainty, never fabricate evidence.
- Mostro Compatibility — complement Mostro, never duplicate or weaken its authority.
Roadmap
- Phase 1 — Detection + notification: shipped.
- Phase 2 — Lifecycle + re-notification + assignment visibility: shipped.
- Phase 3 — Guided Mediation (low-risk coordination failures): shipped on
main. Contacts dispute parties via gift wraps to their shared pubkeys, runs bounded clarifying rounds, classifies through a versioned prompt bundle + reasoning provider, and either delivers a cooperative summary to the assigned solver or escalates with a Phase 4 handoff package. Strict policy-layer validation suppresses any output that would cross Serbero's authority boundary. - Phase 4 — Escalation Execution: planned. Phase 3 already prepares the
handoff_preparedpackage (evidence refs, rationale refs, prompt bundle id, policy hash); Phase 4 will consume it — routing to write-permission solvers, re-escalation on no-acknowledge, and the operator UI surface. - Phase 5 — Additional Reasoning Adapters: the OpenAI-compatible adapter shipped in Phase 3 already covers hosted OpenAI, vLLM, llama.cpp, Ollama, LiteLLM, and any router proxy exposing
/chat/completions. Vendor-specific adapters (Anthropic, PPQai, OpenClaw) are tracked as future work behind anot_yet_implementedguard that fails loudly at startup so operators get an actionable message rather than silent coercion.
Release a New Version
Serbero uses cargo-release to automate versioning and tagging.
# Install cargo-release (once)
# Bump patch version (0.1.0 → 0.1.1), commit, tag, and push
# Or bump minor (0.1.0 → 0.2.0)
# Or bump major (0.1.0 → 1.0.0)
Pushing a tag v*.*.* triggers a GitHub Actions workflow that builds binaries for all supported platforms and publishes them to the Releases page. Tags containing -rc or -beta are marked as pre-releases automatically.
You can also create a release manually with git tag:
License
Serbero is licensed under the MIT License.