seller: persistent node — durable store + agent roster under one identity - #135
Merged
Conversation
…tity Introduce crates/mobee-core/src/seller_node: the durable substrate the seller loop moves onto, mirroring the buyer daemon's shape (exclusive home lock, identity + receiving wallet behind serialized actors, durable sqlite lifecycle store). Concurrency stays 1 — this removes hidden state races before any parallelism. - store: WAL sqlite as source of truth for offers/claims/awards/jobs/deliveries/ receipts + a nostr event outbox. Every transition is idempotent; each published event is enqueued in the SAME transaction as the state change, deduped on a stable key, and signed at a fixed created_at so re-publish is relay-idempotent. - outbox: async publisher that drains pending rows, retrying until confirmed or expired, over an EventPublisher seam. - ingester: the single relay→store writer, over an EventStream seam. - signer / wallet_actor: the seller key and the receiving cdk wallet, each owned by one serialized task; the secret never leaves the signer. - roster: heterogeneous agents under ONE seller pubkey, selected deterministically by capability + rate; empty roster falls back to the single agent_command. Gated under `wallet` exactly like the buyer daemon (no new flag; #133 owns the flag reassessment). Does not touch buyer/ or mcp.rs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… wire-valid The durable outbox stored only kind+content, so a signed event carried no tags — but the live protocol requires ["v","0"] + ["t","mobee"] (+ e/p routing) and parse_offer rejects an event missing/mismatching them. Store the full EventDraft (kind + content + tags) in the outbox instead, and have the signer apply the draft's tags via the canonical gateway::nostr::event_builder (no hand-rolled tag handling). Adds a test asserting a signed event carries ["v","0"] and ["t","mobee"]; strengthens the outbox red-on-revert tooth to assert the enqueued draft carries the protocol tags. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This was referenced Jul 23, 2026
orveth
pushed a commit
that referenced
this pull request
Jul 25, 2026
Slice 1 of mobee×buzz. The seller node enrolls as a buzz inhabitant under
its existing protocol identity: it publishes a NIP-01 kind-0 persona carrying
a human-readable rate card and maintains a live presence heartbeat while up.
Config-off (no `[buzz]` section) is fully inert.
- home::BuzzConfig — optional `[buzz]` section (relay URL + persona: name,
about, rate, capabilities, mint, heartbeat cadence).
- seller_node::buzz — the persona subsystem:
* kind-0 rate card published on boot, rate/caps/mint assembled into a
human-readable `about` (rate_card_about, pure + unit-tested);
* clobber guard — kind-0 is one-per-key replaceable, so before the first
publish the node fetches the key's current kind-0 and REFUSES a foreign
one (no mobee marker), fail-closed if it cannot read it (clobber_decision,
pure + unit-tested);
* presence — a live WS connection publishing an ephemeral kind-20001
PRESENCE_UPDATE every 30s; clean disconnect on shutdown clears presence,
crash lets the relay TTL expire it.
- One identity, one signer. The persona (and any NIP-42 auth the relay asks of
the presence socket) is signed by the existing seller_node::signer actor via
NodeNostrSigner, so the seller key never leaves the actor. The only signer
change is a new sign_unsigned command that refuses a foreign-authored event.
- SellerNode::start_buzz — the boot hook (Ok(None) when `[buzz]` absent). The
`mobee sell` -> node cutover (separate slice) will call it.
Zero changes to any money-path file, to buyer/, or to mcp.rs. PROTOCOL_VERSION
"0" untouched. Live kind-0 + presence logic proven against an in-process
nostr-relay-builder fixture (seller_node::buzz_relay_it); a deployed-relay
round-trip is wired behind #[ignore] for the live check once demo keys are
admitted.
Stacks on #135 (seller-node-durable-store-roster).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Introduces
crates/mobee-core/src/seller_node— the durable substrate the seller loop moves onto, mirroring the buyer daemon's shape (crates/mobee-core/src/buyer) per #131: an exclusive per-home lock, the seller identity and the RECEIVING cdk wallet behind serialized in-process actors, and a WAL sqlite lifecycle store as the source of truth. Concurrency stays 1 — the point of this step is to remove hidden state races before any parallelism.Build order phases 1–2 from the settled DESIGN (
projects/mobee-seller-node/DESIGN.md):seller_node::store(WAL,synchronous=FULL, FK on) is the source of truth foroffers / claims / awards / jobs / deliveries / receipts+ a nostr event outbox. Every transition is idempotent; the money-safe dedups are DB invariants (awards.award_idUNIQUE,receipts.receipt_idUNIQUE, outboxdedup_keyUNIQUE). Every published event is enqueued in the same transaction as the state change that produced it, and signed at a fixedcreated_atso a re-publish is relay-idempotent.outboxis the async publisher (retry until confirmed/expired) over anEventPublisherseam;ingesteris the single relay→store writer over anEventStreamseam;signer/wallet_actorown the key and the receiving wallet — the secret never leaves the signer.[seller_roster]config declares heterogeneous agents (argv + capabilities + min_rate_sats + timeout_secs + slots) under ONE seller pubkey.roster::select_agentreplaces the single hardcoded command with deterministic capability+rate selection; an empty roster falls back toSellerConfig.agent_command(existing sellers behave identically). Public claims expose terms, never the internal agent name.Hard boundaries honored
PROTOCOL_VERSIONunchanged; kinds unchanged; wire-compatible with live sellers (no relay behavior added — this is the substrate).buyer_fund::open_wallet_async(shared helper, notbuyer/). Secrets/tokens never printed.crates/mobee-core/src/buyer/orcrates/mobee/src/mcp.rs.Feature-gate choice (re: #133)
Gated under
wallet, exactly like the buyer daemon — no new flag.seller_nodeneeds the same deps the buyer daemon does (rusqlite, cdk, nostr-sdk, tokio net/sync/time), all already pulled bywallet. This is the simplest option consistent with #133's direction (the node is identity/wallet/relay/lifecycle plumbing; fewer flags shrinks the cfg-drop blind spot). When #133 lands its buyer/seller-split decision it can move both daemons together. A// do not restructure the flags here (that is #133's job)note is left at thelib.rsmodule decl, matching the buyer's.Scope note
This ships the durable substrate + reconcile-on-start + roster selection, exercised end to end via injected
EventStream/EventPublisherseams. The cutover ofmobee sellonto the node (live relay subscription decoding intoIngestEvents, execute, collect) is the next slice — mirroring how the buyer daemon shipped its shell first (#131) and wired the trade path incrementally.mobee sellis left on the existing daemon so nothing live changes in this PR.Acceptance (all green; run on turtle, rust 1.94.0)
The two required teeth
seller_node::tests::restart_resumes_job_state_and_never_double_publishes: parks a claim, publishes it, records the award, marks executing, then drops the node ("crash"). Reopen on the same home →reconcile_on_startresumes the job asExecutingfrom sqlite, and re-enqueue + re-drain of the already-published claim publishes nothing (outbox dedup). Red-on-revert verified: neuteringresumable_jobs(reconcile not reading sqlite) makes the resume assertion fail.seller_node::store::tests::tooth_outbox_write_lands_atomically_with_the_claim: asserts the outbox row landed (pending, right kind/payload/dedup_key), not merely absence of error. Red-on-revert verified: deleting theenqueue_eventcall inclaim_and_enqueueleaves the claim row but no outbox row → the test fails ("exactly one pending outbox row must exist").Rebase note
Trivial conflicts expected in
mobee-core/src/lib.rs(module decl) andhome.rswith the in-flight buyer PR — not pre-solved; whoever merges second rebases.