Skip to content

seller: persistent node — durable store + agent roster under one identity - #135

Merged
orveth merged 2 commits into
mainfrom
seller-node-durable-store-roster
Jul 24, 2026
Merged

seller: persistent node — durable store + agent roster under one identity#135
orveth merged 2 commits into
mainfrom
seller-node-durable-store-roster

Conversation

@orveth

@orveth orveth commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

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):

  1. Durable seller node, single-flight. seller_node::store (WAL, synchronous=FULL, FK on) is the source of truth for offers / claims / awards / jobs / deliveries / receipts + a nostr event outbox. Every transition is idempotent; the money-safe dedups are DB invariants (awards.award_id UNIQUE, receipts.receipt_id UNIQUE, outbox dedup_key UNIQUE). Every published event is enqueued in the same transaction as the state change that produced it, and signed at a fixed created_at so a re-publish is relay-idempotent. outbox is the async publisher (retry until confirmed/expired) over an EventPublisher seam; ingester is the single relay→store writer over an EventStream seam; signer / wallet_actor own the key and the receiving wallet — the secret never leaves the signer.
  2. Agent roster under one identity. [seller_roster] config declares heterogeneous agents (argv + capabilities + min_rate_sats + timeout_secs + slots) under ONE seller pubkey. roster::select_agent replaces the single hardcoded command with deterministic capability+rate selection; an empty roster falls back to SellerConfig.agent_command (existing sellers behave identically). Public claims expose terms, never the internal agent name.

Hard boundaries honored

  • Agents produce files; the node signs/commits/publishes/receives. No agent process holds the seller key or the receiving wallet.
  • PROTOCOL_VERSION unchanged; kinds unchanged; wire-compatible with live sellers (no relay behavior added — this is the substrate).
  • Testnut-only path unchanged; the node opens the wallet via the existing buyer_fund::open_wallet_async (shared helper, not buyer/). Secrets/tokens never printed.
  • Did not touch crates/mobee-core/src/buyer/ or crates/mobee/src/mcp.rs.

Feature-gate choice (re: #133)

Gated under wallet, exactly like the buyer daemon — no new flag. seller_node needs the same deps the buyer daemon does (rusqlite, cdk, nostr-sdk, tokio net/sync/time), all already pulled by wallet. 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 the lib.rs module decl, matching the buyer's.

Scope note

This ships the durable substrate + reconcile-on-start + roster selection, exercised end to end via injected EventStream/EventPublisher seams. The cutover of mobee sell onto the node (live relay subscription decoding into IngestEvents, execute, collect) is the next slice — mirroring how the buyer daemon shipped its shell first (#131) and wired the trade path incrementally. mobee sell is left on the existing daemon so nothing live changes in this PR.

Acceptance (all green; run on turtle, rust 1.94.0)

$ cargo test -p mobee-core --no-default-features --features gateway,git-delivery,wallet --release
test result: ok. 535 passed; 0 failed; 0 ignored    (rc=0)
  (incl. all 26 seller_node:: tests — store/outbox/ingester/roster/signer/wallet_actor/node)

$ cargo build -p mobee-core --features acp --release
Finished `release` profile    (rc=0)

$ cargo test -p mobee-core --features acp --release
test result: ok. 167 passed; 0 failed; 0 ignored    (rc=0)

$ cargo build -p mobee --release
Finished `release` profile    (rc=0)

The two required teeth

  • restart-survivalseller_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_start resumes the job as Executing from sqlite, and re-enqueue + re-drain of the already-published claim publishes nothing (outbox dedup). Red-on-revert verified: neutering resumable_jobs (reconcile not reading sqlite) makes the resume assertion fail.
  • outbox red-on-revertseller_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 the enqueue_event call in claim_and_enqueue leaves 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) and home.rs with the in-flight buyer PR — not pre-solved; whoever merges second rebases.

claude added 2 commits July 23, 2026 13:56
…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>
@orveth
orveth merged commit 3f845cc into main Jul 24, 2026
@orveth
orveth deleted the seller-node-durable-store-roster branch July 24, 2026 18:01
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>
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.

2 participants