seller-node cutover: mobee sell boots the durable seller node - #169
seller-node cutover: mobee sell boots the durable seller node#169orveth wants to merge 23 commits into
Conversation
…t (cutover foundation) Foundation for the seller-node cutover (charter invariants 8 and 2). Primitives plus their unit teeth; the end-to-end invariant teeth (config-drift cosig still verifies; resume adopts the pushed tip) land with the run-loop wiring where the production delivery path lives. - store: journal the seller creq (NUT-18 payment request) authored at claim time into the claims table (`creq` column), threaded through `claim_and_enqueue` and read via `job_creq`. One source of truth for the trade's payment terms: the delivery cosig signs its hash (audit N-4 — never a rebuild from live config) and the restart redeem-guard settles against its mints (Fix Q — original terms). Teeth: the claim-time creq is immutable across idempotent replays. - seller_git: add `snapshot_delivery_at` taking an explicit journaled author-date in place of `Signature::now()`, so a delivery commit re-created after a restart is byte-identical (same oid => re-push is a no-op, never a divergent second tip). `snapshot_delivery` stays as a thin wall-clock delegator until the legacy path is deleted. Teeth: the committed author/committer time equals the journaled date and two identical workdirs snapshot to the same oid (reverting to now() stamps wall-clock => red). Money-suite green (557 passed): cargo test -p mobee-core --no-default-features --features gateway,git-delivery,wallet --lib Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The EventPublisher the node's drain loop uses: sign each pending outbox draft THROUGH the signer actor (sole holder of the seller key) with the row's fixed created_at, then send the pre-signed event to the seller's relay and return the published id. Deterministic created_at => a re-publish after a crash is the same event, which the relay dedupes (the store's dedup_key stops the second enqueue; this stops the second on-wire event). One long-lived client per node (team-lead approved), matching one-process / one-identity; the client is built with a throwaway identity because it only ever sends pre-signed events, so the seller secret never leaves the signer actor. Real publish is covered by the live smoke; the drain-loop logic that calls it is already unit-tested against a fake publisher (outbox::tests). Money-suite still compiles clean under --features gateway,git-delivery,wallet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ommit) The tooth compared two delivery snapshots built on SEPARATE base commits; each base is stamped with wall-clock time, so the bases (and thus the delivery parents, and thus the oids) differed whenever the two init calls straddled a second — a timing-dependent false failure. Snapshot the same base twice instead: same base + tree + identity + journaled date ⇒ identical oid, deterministically. The load-bearing bite is unchanged (committed author/committer time == the journaled date; revert to Signature::now() ⇒ red). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
wait_for_nip42_auth + AuthWait move to a neutral crate::relay_auth module (gated on `gateway`) with a dedicated RelayAuthError, so they survive seller_daemon's deletion in the cutover. Both consumers re-point there: the buyer receipt-publish gate (authorize_pay) and — until it is deleted — the seller loop, via a pub(crate) re-export plus a From<RelayAuthError> for DaemonError bridge. Behavior-identical: the money suite's nip42 gate tests (nip42_auth_wait_* — authenticated / timeout / shutdown / channel-closed / auth-failed) stay green. Money-suite 558 passed / 0 failed under --features gateway,git-delivery,wallet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
SellerNodeRunner::boot opens the durable node, reconciles durable state, then connects ONE authenticated relay client and builds the shared publisher over it: - Key custody (team-lead approved option a): the seller key lives in exactly two places — the signer actor and this one authenticated relay client, constructed at a single site with the secret read once and dropped. No accessor exposes it, it is never logged/serialized. The client holds it because mobee-relay authenticates the seller via NIP-42 before delivering p-gated kind-1059 wraps. Follow-up (post-#158): converge onto the NostrSigner-over-actor adapter to collapse custody back to actor-only. - NIP-42 handshake via the neutral crate::relay_auth (Authenticated / NoChallenge degrade / fatal), notifications subscribed before connect. - RelayPublisher now shares the runner's authenticated client (Arc clone) instead of opening its own — one connection, one NIP-42 session. run() subscribes the targeted offer/award/gift-wrap filters and runs the select loop: a periodic tick drains the outbox; each event dispatches by kind. The offer->claim, award->execute, gift-wrap->pay arms + the #150 watchdog + #162 recovery are ported on top of this next (marked PORT); `mobee sell` is NOT yet pointed here, so this is inert scaffold. Money-suite 558 passed / 0 failed; runner compiles clean (no new warnings). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ted) The run loop's offer arm now classifies and claims for real, reusing the neutral helpers (parse_offer, rate_gate_allows, job_deadline_unix, build_seller_creq, claim_draft): parse -> refuse a lapsed offer BEFORE re-deriving its deadline (never resurrect a stale offer) -> refuse/skip a contribution offer (later slice) -> rate/targeting gate -> capacity back-pressure -> journal the claim. The claim-time creq is authored from the seller's OWN config and journaled via claim_and_enqueue in one transaction (claim row + outbox row), so delivery later signs the STORED creq's hash (invariant 8) and the restart redeem-guard reads its mints (Fix Q) — never a rebuild from live config. The publish rides the shared outbox drain. Teeth (pure classify decision, unit-tested): a fresh in-rate targeted offer claims with the resolved deadline; a lapsed offer is refused BEFORE the rate gate (money-safety order); below-rate skips; untargeted needs open-pool opt-in. Contribution execution and open-pool/backfill subscriptions are follow-up slices. Money-suite 562 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ite) on_offer now records the offer (buyer/amount/unit/task/deadline/targeted) into the store before parking the claim, and store gains offer_facts(offer_id) -> (buyer, amount, unit). The award arm reads the buyer to authorize an award (the award author MUST be the offer's buyer, never a third party) and the pay path reads amount/unit as the redeem terms — both from durable state, so they survive a restart. Idempotent (re-seen offer is a no-op). Store test asserts offer_facts round-trips and returns None for an unknown offer. Money-suite 562 passed / 0 failed (one pre-existing telemetry parallelism flake confirmed green in isolation + on re-run; unrelated to this change). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
on_award authorizes and binds: parse the award, confirm we recorded the offer and hold a parked claim, drain so our claim id is on the wire, then the pure match_award decides. Authorization is the security-critical rule — only the offer's buyer may drive execute or release; a spoofed award (author != buyer) can do neither. On a match to OUR published claim id it records the award (claim -> awarded, job row created); on a different claim id it releases ours (the buyer picked another seller); an unpublished claim or a non-buyer author is ignored. Teeth (pure match_award, unit): a non-buyer author is ignored even when the claim id matches; the buyer's award binds our claim (Execute), a different claim id releases ours, and an unpublished claim is ignored. Executing the awarded job (agent run + delivery signing the STORED creq's hash) is the next arm. Money-suite 564 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…inv 8 tooth) Lands the two money-critical correctness properties of the delivery/execute arm ahead of its agent+git body, each teethed: - INV 2 (re-push determinism): store.job_award_time(job_id) exposes the award's persisted created_at as the delivery commit's authored-at — a durable, restart-STABLE value, so a re-created delivery after a restart is byte-identical (feeds snapshot_delivery_at). Store test asserts it round-trips / None when unawarded. - INV 8 / audit N-4 (creq bound at claim): a unit tooth proves the delivery cosignature signs the hash of the STORED claim-time creq, never a rebuild from live config — author a creq under one accepted-mint set, drift the config to a different set, and the drifted creq hashes DIFFERENTLY from the stored one that delivery must sign. This is the charter's "mutate accepted_mints between claim and delivery" bite at the unit layer. The agent-run + snapshot_delivery_at + push + deliver_and_enqueue body wires these in next. Money-suite 565 passed / 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n the actor) Add SignerHandle::sign_receipt_hash — the execute arm's delivery co-signature. The actor schnorr-signs the receipt-preimage digest (computed by the caller from the STORED creq, audit N-4) with the seller key, so the key never leaves the signer actor: the execute body signs through the actor rather than re-reading the secret, preserving the money-safe custody boundary. Test: the actor returns a 64-byte (128-hex) signature that is never the secret; a malformed digest fails closed. Receipt-sign test green; the one failing test in the run is the pre-existing telemetry parallelism flake (sink_failure_*_jsonl_append), green in isolation, unrelated to this change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Move run_agent_job / compose_agent_prompt / job_workdir / seller_delivery_kind (plus their exec cluster: run_agent_with_retry, unified_job_timeout, delivery_message, seller_exec_metadata, harness_and_transport, short_hash) out of seller_daemon into a new crate::seller_exec module with a neutral ExecError, so the durable node can reuse them and seller_daemon deletes clean at the cutover — same decoupling pattern as relay_auth. seller_daemon re-points via imports + a From<ExecError> bridge; the helpers' unit tests move with the code. Behavior-identical: money suite 566/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The award Execute branch now runs the awarded job end to end via the durable node: mark_executing -> init empty-base workdir -> run the agent (bounded retries within the job deadline) -> snapshot ONE delivery commit dated at the STORED award time (inv 2: deterministic re-push) -> push under the seller NIP-98 auth (key read into PushAuth, dropped immediately) -> bind trade + delivered commit + STORED claim-time creq hash (audit N-4 / inv 8) into a co-signature signed through the signer actor (key never leaves it) -> journal delivery + enqueue the result event in one idempotent transaction -> drain. Failures fail the job with a named reason and publish nothing partial. New store reader offer_row (task + deadline beyond offer_facts). Pure delivery_receipt_preimage factored for the inv-8 tooth. Teeth (money suite 568/0): - delivery_preimage_binds_stored_creq_not_drifted_config (inv 8): preimage built from store.job_creq binds the STORED creq hash, not a drifted-config rebuild. - resume_redelivery_is_deterministic_and_never_double_publishes (inv 2): identical tree + stored author date => identical oid; deliver_and_enqueue dedups on resume (exactly one result enqueued). STRONG BITE: neutering deliver_and_enqueue dedup makes this red (rc=101). - snapshot determinism also covered by seller_git tooth_delivery_snapshot_uses_journaled_date. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(custody) The execute body previously re-read the seller secret into a PushAuth for the delivery push — a THIRD custody site beyond the rule's two (signer actor + authed relay client). A relay-git push authorization is a single NIP-98 (kind-27235) event signed once over the repo-root URL, so it routes through the actor like the receipt-hash: new SignerHandle::http_auth_header builds the header with the actor's Keys and hands back only the 'Nostr <base64>' string; the key is never re-read. git_transport: nip98_authorization_header_with_keys (Keys-based) + push_branch_with_header (takes a resolved header); push_branch delegates. seller_git::push_branch_with_header wraps it. run.rs execute_job signs the header via the actor (gated to relay-git remotes; public https takes none) and drops the PushAuth path. seller_daemon (being deleted) keeps its secret-based push_branch_with_auth unchanged. Tooth: signer builds_nip98_push_header_through_the_actor_never_leaking_the_secret (valid Nostr header, secret never present, malformed url fails closed). Money suite 569/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The GiftWrap branch now settles payments store-backed. Ordering (inv 3): decode + seal-auth -> guards -> intent breadcrumb BEFORE the swap -> mint swap -> fail-closed classify -> receipt row (deduped by wrap id). Every refusal logs a named reason; nothing partial is recorded. Custody: the NIP-44 gift-wrap decode runs INSIDE the signer actor (new SignerHandle::unwrap_payment_wrap) so the seller key never leaves it. cdk's P2PK receive needs the raw cashu key, which has no signature-only path, so it is DERIVED inside the actor from the seller key (new cashu_p2pk_secret) and exported only as the derived payment key — the seller identity key still never leaves the actor. FLAG for PR review: this derived-cashu-key export is the one unavoidable materialization on the receive path; alternative (a wallet-actor-owned cashu key) is a larger refactor — calling it out for the gate to rule on. Money-safety guards, all faithful to the daemon: - seal sender MUST == bound offer buyer (third-party settlement refused); - realized mint MUST be in the STORED claim-time creq (Fix Q — parsed from store.job_creq, not live config), + allow_real_mints fence; - classify_redeem_outcome (finding S): NEVER infer collection from the breadcrumb; the only proof of our prior collection is a COMPLETED receipt read FAIL-CLOSED (read error -> refuse); - collect_receipt dedups on the wrap id (job paid at most once). New store: pending_receive breadcrumb table + append_pending_receive + has_receipt. Teeth (money suite 573/0): - redeem_classification_finalizes_and_never_forges_from_a_breadcrumb (replay-of-collected -> no-op; crash-between-import-and-receipt -> refuse; read-error -> refuse). STRONG BITE: neutering the never-forge branch to Finalize makes it red (rc=101). - seal_sender_must_be_the_bound_offer_buyer (third-party refused). - realized_mint_must_be_in_the_stored_creq (mint-outside-creq refused). - receipt_collect_dedups_a_replayed_payment (paid at most once). Signer: unwrap + cashu-key commands covered by the existing actor tests' shape. The live cdk receive is exercised by the step-8 testnut smoke (the daemon likewise does not unit-test live cdk receive; the money safety is the pure classify + dedup + guards). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ry retry The run loop now publishes an own-heartbeat (kind-30340) each heartbeat tick and runs the #150 relay-stall watchdog: it subscribes to its OWN heartbeat, so each published heartbeat round-trips on the live subscription. If none has round-tripped within the stall threshold (interval * missed_intervals, config-driven) the subscription is presumed dead — disconnect, reconnect, re-run NIP-42, and resubscribe ALL filters (offers+awards+1059+self-heartbeat) with since=overlap (last-good minus 60s margin) so events published during the stall backfill through the idempotent handlers. Own kind-30340 coming back refreshes the liveness clocks and is dropped (never routed to offer/award/pay). Subscriptions are cleared only AFTER a successful reconnect+auth, so a failed recovery never leaves the node reconnected-but-deaf. #162 folded in: a connect-phase failure (relay drops the socket before NIP-42 completes) is retried up to 3x with a short backoff WITHIN one recovery before yielding to the next tick; success logs 'RELAY-STALL recovery SUCCEEDED (attempts=N, outage=Ss)'. Ported verbatim from seller_daemon: stall_threshold_secs, subscription_stalled, self_heartbeat_subscription_filter, STALL_OVERLAP_MARGIN_SECS. Heartbeat publish routes through the signer actor (sign) + shared client (send_event_to). subscribe_all is shared by boot and reconnect so both subscribe the same set. Tooth: watchdog_stall_math_clamps_and_trips_only_at_threshold (never trips on the first tick; trips at/after threshold). Money suite 574/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Swap sell.rs's boot site from the in-memory SellerDaemon to SellerNodeRunner: now runs the durable node (sqlite store + outbox + reconcile_on_start) as its real path. The sync run_sell owns a current-thread runtime (the same bridge run_forever_blocking used) and block_on's the async boot + run loop. seller_daemon is now unreferenced by the binary — the delete follows in the next step. Invariant 7 (buzz leg degrades, never bricks) is satisfied by construction: the node run loop wires no start_buzz(), so there is no buzz dependency that could brick boot; the node boots and sells with any buzz relay down because it never contacts one (documented for the PR). cargo build -p mobee: rc=0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mobee sell now boots the durable node, so the whole in-memory single-flight lifecycle (seller_daemon/, ~4200 LOC across mod.rs + tests.rs + local_relay_it.rs) is dead and removed. Cleancut: the code reads as if the node were always the seller path — no transitional shim, no cfg-gated fallback. grep proves zero remaining seller_daemon / SellerDaemon / run_forever_blocking references in the workspace. nip42 was already relocated to relay_auth (phase 1); the only wait_for_nip42_auth callers now are relay_auth's own home plus the buyer path in authorize_pay and the node run loop. Also: narrowed SellerNode::signer() pub -> pub(crate) (every caller is the in-crate run loop), and scrubbed the three stale doc/comment references to the deleted module. Money suite (no-default-features gateway,git-delivery,wallet): 488/0. cargo build -p mobee: rc=0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…iant 4) A process restart can leave a job journaled as awarded (award seen, delivery not started) or executing (interrupted mid-run). The run loop now, once at boot after reconcile, re-drives execute_job for every such job so a seller that dies mid-job resumes rather than losing the award — the slice's field promise (nous's Mac kills processes mid-execution). Re-execution is idempotent: the delivery snapshot is deterministic (stored award-date) and deliver_and_enqueue dedups, so a re-created delivery lands exactly once. Delivered jobs are left for the pay path; terminal (paid/failed) never re-run. The ACP session/load enrichment (resuming the agent's own session vs a fresh re-run + workdir inspection) is a documented follow-up; this is the fallback form of invariant 4. Pure should_resume_execution predicate. Teeth: resume_selects_awarded_or_executing_not_delivered_or_terminal; boot_resume_re_drives_awarded_undelivered_job_delivery_lands_once (resume-eligible + delivery once). Also carries a clippy too_many_arguments allow on delivery_receipt_preimage. Money suite 490/0. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paired cutover smoke — BUYER side (PAID through the seller-node path) ✅Live testnut trade through the durable seller node (this PR's tip binary), driven by the Buyer Two agent calls (
Invariants observed live:
First attempt — a real finding (not a node bug)The first job ( Cost: 4 sats testnut (well under the ≤10-sat shared ceiling). Both sides exercised through the node path. |
Live testnut smoke — PASSED (paired with #166's buyer)Seller = this PR's tip binary ( Seller-side sequence (node path), job Buyer side (above): kind-3400 co-signed receipt Also proven live at boot: Honest findings the live run surfaced (candidates for the delta commit / follow-up)
|
…mpotency (gate + smoke)
Four node-side restorations the gate bounce and the live smoke surfaced, one commit:
(1) UnsupportedVersion classification. on_offer collapsed a cross-version offer into the
generic unparseable bucket, regressing #146 and the #117 taxonomy. Now matches
OfferParseError::UnsupportedVersion distinctly (version skew is not malformed tags).
(2) Under-rate refusal feedback. A TARGETED-to-self offer priced below the rate floor now
emits a kind-feedback status=error to the buyer (open-pool under-rate stays log-only;
a lapsed skip never emits) so the buyer learns why instead of getting silence. Signed
through the signer actor; ClaimDecision::Skip now carries a typed SkipReason so only the
rate-gate skip (never lapsed) is eligible.
(3) Execute idempotency guard (money-adjacent, live-caught). mobee publishes TWO kind-3405s
per settled trade by design — the award at selection AND the accept at settlement — and
they are indistinguishable on the wire (both status=accepted, same offer+claim e-tags).
Without a guard the accept-3405 re-triggered execute_job for the already-delivered job:
record_award returns Awarded::New (the jobs row INSERT-OR-IGNOREs while the fresh award_id
inserts), so the node re-ran the agent (operator compute paid twice, EVERY trade) and its
push was rejected non-fast-forward, transiently marking the delivered job failed.
execute_job now early-returns unless the job is still awarded/executing; delivered/paid/
failed are a no-op that never clobbers the terminal state. (credit: mobee-buyer-lazystart
tag-level analysis — c57046fd award-at-selection, 7a77a6c5 accept-at-settle.)
(4) Execution-failure feedback. A post-award execution failure now sends the buyer a
kind-feedback status=error (generic, path-free reason) at every post-offer fail point, so
the buyer does not wait on a delivery that will never come. Generalized the feedback
publisher; fail_job_with_feedback wraps fail_job.
Teeth (money suite 494/0): unsupported_version_offer_is_a_distinct_parse_refusal;
under_rate_feedback_only_for_targeted_under_rate_rate_gate; delivered_or_paid_job_is_not_re_executed
(STRONG BITE: neuter should_resume_execution to admit terminal states -> rc=101 red, reverted);
execution_failure_feedback_is_a_buyer_addressed_error. The 3 existing classify_offer teeth
re-pinned to the typed SkipReason.
Follow-up (tracking issue, later slice): mobee-core should differentiate award vs accept on the
wire (accept e-tags the RESULT id) so sellers can tell binding from selection without the guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Delta ccfcdc2 — gate bounce + smoke findings fixed (fix-before-merge)The gate's two items plus the two robustness gaps the live smoke surfaced, one commit, all teethed (money suite 494/0):
Follow-up (tracking issue, later slice): mobee-core should differentiate award vs accept on the wire (accept e-tags the RESULT id) so sellers can tell binding from selection without the guard. Gate re-check requested. All the money/delivery invariants + prior teeth stay green; strong bites hold on inv-2 (deliver dedup), inv-3 (never-forge), and now the idempotency guard. |
Union-resolved with the #169 cutover in seller_node/{mod,signer}.rs: both sides' actor commands kept; signer() stays pub(crate) (cutover's narrowing) with the persona's default-deny rationale folded into the doc.
…o recover The watchdog could neither fire correctly nor recover. Both halves were structural, and together they produced the field signature: a healthy node claiming, delivering and getting paid while its watchdog screamed stall and re-dialed the shared relay three times a minute, indefinitely. TRIGGER — the liveness probe was unsatisfiable. `RelayPool::send_event_to` saves every published event into the client's own database (pool/mod.rs:767), and the inbound handler drops any event already present there without notifying (relay/inner.rs:1215; the notification lives only in the `NotExistent` arm). A client can therefore never be delivered its own published event, so "my own kind-30340 round-tripped" could never be observed: `last_self_heartbeat_seen` was set once at loop start and never refreshed, and every node declared a stall at `stall_threshold` and stayed there, healthy or not. Replaced by a `limit(0)` REQ whose EOSE proves the relay is serving OUR subscriptions on THIS authenticated session — no cooperating publisher, no stored events, and it also catches a CLOSED-while- socket-up subscription death. The own-heartbeat subscription goes with it: it could only ever have returned nothing. Heartbeat publishing stays — kind-30340 is presence/discovery that other agents read, and that part always worked. RECOVERY — `reconnect_and_resubscribe` took the relay notification receiver BEFORE `client.disconnect()`, and `Relay::disconnect` emits `RelayNotification::Shutdown` into exactly that channel (relay/inner.rs:1288). The auth wait consumed our own Shutdown and reported "relay shutdown before NIP-42 authentication" on a socket that had authenticated perfectly: 0 successes in 969 field attempts. Disconnect first, then take the receiver — still before `connect`, so the one-shot `Authenticated` cannot be missed either. That Shutdown is relay-internal (`external = false`), never reaching the pool notifications the run loop watches, which is why this presented as a silent wedge rather than a crash. Also in the same subscription path: - The open-pool offer filter, dropped in the cutover. `claim_open_pool` survived only in the classify gate, so an opted-in seller ran a claim gate over offers its REQ could never deliver, and `offer_backfill_secs` was ignored outright. Both offer filters carry the `#t=mobee` namespace guard again, so a foreign event squatting the offer kind is never delivered. - A `CLOSED` arm. A relay-closed subscription kills one leg while the socket stays up, so it is invisible to a socket-level check: now it is loud, names which leg died (stable per-role subscription ids instead of generated ones), degrades the offer REQ to targeted-only rather than losing it wholesale, and repairs everything else through the one paced recovery path. - Exponential, capped backoff between recovery attempts. A flat interval is what let a wedged node re-dial shared infrastructure three times a minute. - The log now names WHICH path restored the receive leg, manual recovery or the SDK's background reconnect. Their indistinguishability is how a path that never once succeeded went unnoticed. - Offer parse refusals route through one function, so the version-skew refusal taxonomy is pinned by test rather than by comment. - The paid line names the kind-1059 payment wrap it actually logs, rather than labelling it a kind-3400 receipt the seller never sees on that path. Teeth: in-process reconnect re-authenticates and delivery resumes, against a fixture that enforces NIP-42 before serving a REQ (the previous fixture served reads unauthenticated, which is how the ordering bug shipped green) and publishing from a separate client, because a client cannot observe its own echo; probe answers on a healthy authenticated session and reports loss within the window on a session the relay refuses to serve; open-pool filter present iff opted in, with the namespace guard on both; backoff grows and stays capped; version-skew refusal stays distinct from the generic bucket. Money suite: 498 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review-fix delta @563672ed — the watchdog can now detect, and can now recover (#171)Scope note up front, because this delta is wider than "fix the reconnect": review of #171 turned up two structural bugs plus, in the same subscription path, a filter set the cutover had dropped. Sections 3 and 4 are deliberate scope, ruled in rather than assumed — flagging them so the gate reviews them consciously. Everything below is one commit, 1. The trigger — the watchdog's liveness probe was unsatisfiable
The #150 watchdog's whole liveness signal was "my own kind-30340 heartbeat round-tripped." Unobservable by construction: Field proof it was never a real stall: the devsoak specimen fired its first stall at 180s (line 21, earliest possible tick) and then went on to claim, deliver, and get paid twice (lines 29-101), receiving offers, awards, and kind-1059 payment wraps throughout. It was healthy the entire time and self-harming. Why the old daemon didn't show this: its So the distinction is sharp: old's watchdog was satisfiable and therefore meaningful, but only accidentally so — it depended on a publish path that reads as pure inefficiency. The cutover's consolidation onto one authenticated client, right for custody and for not opening a socket per publish, silently converted a working detector into one that can only ever cry wolf. Behaviour preservation needs verifying per-invariant, not per-intent. Replacement: a Tooth —
Bites (rc=101 each, tree restored and verified identical after): probe returns The negative half is also what keeps the probe honest against the real thing: the two genuine old-fleet stalls were subscription deaths, and "relay stops serving our REQs" is exactly the condition it asserts. A replay fixture built from those episodes' The SDK trap is filed as #174 — it's a standing hazard for any future client that both publishes and subscribes (the buyer daemon's delivery-watcher slice especially), and in tests it presents as a transport bug. 2. The wedge — recovery could never succeed
0 successes in 969 field attempts. Not flaky — structural. Wire trace from the relay operator confirms it from the other side: exactly 3 auth→close per 60s cycle, every close preceded by a successful NIP-42 auth, zero relay-side close reasons. Client-initiated, both sides describing the same bug. Presented as a silent wedge rather than a crash because that Shutdown is sent with Fix: disconnect first, then take the receiver — still before Tooth —
Bite: restore the original ordering → red with the field's exact string, 3. Restored: the open-pool offer filter and the
|
| revert | tooth that goes red | rc |
|---|---|---|
receiver taken before disconnect() |
reconnect / delivery-resumes | 101 |
probe returns true on timeout |
probe, negative half | 101 |
| probe matches a different sub id | probe, positive half | 101 |
Tree restored to the commit and verified byte-identical after each.
Not addressed here
- The live testnut smoke in this PR's acceptance block is unchanged and still pending.
- seller node heartbeat/watchdog task parks silently — no ticks, no errors, 0% CPU (distinct from #171) #173 (heartbeat task parking on the nous fleet) is a separate defect, upstream of all of this — a parked tick means the detector never runs at all. Being investigated next.
Probe coverage — resolved. Class (A) evidence-backed; B1/B2 documented as residualAmending the probe section of the delta above. I raised a coverage question against my own change before the gate saw it; this closes it. No code change resulted — the resolution is that the probe covers the only signature the field has ever actually produced. The questionThe probe has to do two things at once:
(1) is satisfied by construction: the EOSE probe has no dependency on observing our own events, so that failure mode cannot recur. (2) I could not claim in general, because the probe mints a fresh
Uncomfortably, in (B1) the old own-echo probe — had it been satisfiable — was strictly more sensitive than this one, because it watched a real long-lived subscription instead of creating a new one. The classification, and why it lands on (A)The two candidate real stalls (old-binary codex seat, 7/24-25) present as (B)-shaped: offers and awards arrived and trades completed during both windows while the detector reported no round-trip. But the only demonstrably absent quantity in either window is the seat's own kind-30340 echo, gone ~22 min, then back. B1 is refuted by one fact: on the old binary, a terminally-removed subscription can never come back.
So terminal removal on old is permanent for the life of the process. The echo returned. The subscription was never removed. What does produce a bounded, self-healing echo gap: kind 30340 is addressable/replaceable, so the relay keeps only the newest event per These episodes are therefore transient own-echo artifacts, not subscription deaths. The dataset contains zero confirmed genuine subscription stalls — every stall signal we have is either this artifact or the unsatisfiable-probe false positive described above. The design error, stated for whoever writes the next probeThe old probe was wrong twice over: accidentally satisfiable (it only worked because publishing went through a throwaway client with a separate database — see #174), and inherently flaky even when satisfiable, because an addressable/replaceable kind is exactly the class of event a relay may legitimately decline to re-broadcast. Round-trip liveness must not be built on a replaceable event. The EOSE probe depends on neither property: it needs no cooperating publisher, no stored event, and no echo. Coverage, as shipped
Stated plainly rather than papered over: this probe is not a universal subscription-liveness oracle. It asserts one property — the relay is serving my REQs on this authenticated session — and asserts it soundly, in both directions, against a fixture that enforces NIP-42 before serving a REQ. The residual classes are named, attributed, and tracked. |
The node could stop dead — heartbeat tick gone, relay-stall watchdog gone, every relay notification gone — at 0% CPU, with nothing logged anywhere. A symbolicated frame from a parked seat put it at the runtime's own park point (`sell.rs` block_on -> current_thread Context::park -> time::Driver -> io::Driver::turn -> kevent) with no libgit2, reqwest or git_transport frame on any thread. A permanent park proves no timer was pending, and any BOUNDED await arms one. That excludes every bounded candidate — nostr-sdk caps its publish path at WAIT_FOR_OK_TIMEOUT = 10s (nostr-relay-pool relay/constants.rs:10), so a half-open socket makes it error in ten seconds rather than hang — and it excludes sitting at the `select!` itself, since `interval.tick()` arms a timer. What is left is the timer-less awaits, and in this node those are the signer actor's oneshot round-trips: six sites, none bounded, all reached from inside `select!` branch bodies. `publish_heartbeat` awaits one BEFORE it ever reaches the publish, so it sits in the tick path itself. All six now route through one bounded `round_trip`. Both legs are bounded, because both are timer-less: `send` parks forever on a full queue the actor is not draining, and `rx.await` parks forever if the actor is alive but never answers. The error names the call and the leg. A bound cannot make a stuck actor answer. What it does is convert an invisible permanent park into a named, logged, recoverable failure at the exact call site — which is why this ships without yet knowing which call stuck in the field: it makes parking silently impossible. Blocking git work moves off the runtime. `init_empty_delivery_workdir`, `snapshot_delivery_at` and `push_branch_with_header` are synchronous libgit2 (the push additionally driving blocking HTTP through the smart subtransport registered in git_transport), and were called inline from async `execute_job`. An async HTTP client would not have helped: the C call owns the thread for its duration either way, and bridging async inside the subtransport callback would reintroduce a nested-runtime block_on. They now run via `spawn_blocking`. This defect is long-standing — `origin/main` has the identical shape, including a current-thread runtime at seller_daemon/mod.rs:2969 — so it was exposed by the cutover's extra call sites and an intermittent host, not introduced by them. On a fast network the blocking window is milliseconds, which is why turtle never parked and nous's macs did. `mobee sell` now runs on a multi-thread runtime (2 workers). The real fixes are above; a worker pool is the complement that stops one stuck task from taking the timer with it. On a current-thread runtime a blocking call does not merely delay other work, it stops time. Adds a `release-debug` profile (inherits release, debug on, strip off). Stock release is stripped and thin-LTO'd, so a spindump of a stuck node yields raw addresses — diagnosing this park required rebuilding the binary before any capture could be read. Tooth: a stalled actor that holds every reply sender forever (dropping one would surface as a recv error, not a park) must not park its caller, and must leave the caller usable for a second call — the property the run loop needs to keep ticking. Time is paused so the bound elapses instantly; the outer timeout is what makes reverting the bound fail cleanly instead of hanging the suite. Money suite: 499 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Delta 3 @408461c6 — the node can no longer park silently (#173)A seller node could stop dead — heartbeat tick gone, relay-stall watchdog gone with it, every relay notification gone — at 0% CPU, with nothing logged anywhere. Same class as the two defects above, one layer deeper: the previous deltas fixed a detector that couldn't fire and a recovery that couldn't succeed; this one fixes the loop that runs them not running at all. How it was found, and what the evidence excludedA symbolicated frame from a parked seat (stable across two captures 60s apart) put the main thread at the runtime's own park point: No libgit2, reqwest or What replaced it came from one constraint: a permanent park proves no timer was pending, and any bounded await arms one. Run that filter over the candidates and almost everything falls:
What survives is the timer-less awaits, and in this node those are the signer actor's oneshot round-trips: six sites, none bounded, every one reachable from inside a 1. Bounded signer round-tripsAll six now route through one The shipping argument, stated plainly: the fix is the diagnostic. A bound cannot make a stuck actor answer. What it does is convert an invisible permanent park into a named, logged, recoverable failure at the exact call site. That is why this ships without yet knowing which call stuck in the field — we don't need to know, to make parking silently impossible. If it recurs, the log will say where. Tooth — Bite: restore the bare 2. Blocking git work off the runtime — long-standing, exposed here, not introduced here
An async HTTP client would not have fixed this, and it's worth recording why: libgit2's push/fetch are synchronous C calls that own the thread for their duration regardless of what the transport does underneath, and bridging async inside the subtransport callback would reintroduce a nested-runtime This defect predates the cutover. It also explains the host differential cleanly: on a fast network a push returns in milliseconds, so the blocking window is invisible — turtle never parked. On a host where pushes hang mid-flight, a current-thread runtime turns "slow" into total: timers, relay I/O and the watchdog stop together. Same binary, same bug; only the network decides whether you ever see it. 3. Multi-thread runtime
4. A build you can actually profileAdds Noticed, not touched
Gates — frozen tip
|
|
Acceptance-block closer — live receipt chain on the frozen final tip
With this, every artifact in the acceptance block exists on the frozen tip: three deltas code-gated (idempotency guard, satisfiable probe + proven re-auth on an auth-enforcing fixture, park structurally removed), plus dual field validation on the host that reproduced both original bugs. |
A live kind-1059 subscription is not sufficient to keep getting paid, and the cutover shipped with nothing else. Field-observed on the in-memory daemon this node replaces (its own comment, seller_daemon/mod.rs:74-81): a fresh subscription delivers a wrap within ~1 min, but a subscription ~10+ minutes old was seen to go deaf and never deliver again — and a payment then sat unredeemed until the process was manually restarted, because the restart re-ran the boot backfill. main carried a periodic re-run of that backfill for exactly this reason (WRAP_BACKFILL_INTERVAL_SECS = 300); the node had no wrap backfill at all, boot or periodic. So a delivered job's payment could sit uncollected indefinitely with no restart-free recovery. Restored on a 300s tick: resolve the cursor, re-fetch stored wraps, route every one through the normal on_gift_wrap path so all the pay-once guards apply unchanged — a re-seen wrap hits the receipt dedup, an already-spent token fails closed — then drain. Re-scanning a wide window is safe by construction, which is what makes the recovery cheap. The cursor is the last collected receipt, clamped to the oldest delivered-but-unpaid job minus a skew margin. The last-receipt timestamp alone is wrong: a receipt for a NEWER job would advance the cursor past an OLDER unsettled job and skip its still-uncollected wrap forever. A store read ERROR aborts the cycle rather than falling back to since=0, which would turn a transient failure into a full-history rescan. Two new store readers back it (last_receipt_unix, oldest_unsettled_delivery_unix). Note what this catches that nothing else can. The liveness probe cannot see this failure: the session still answers our REQs, so the relay is alive by every measure the watchdog has. Three layers, none subsuming another — probe = session liveness, this backfill = money-leg recovery, and a subscription-map reconciler (#172) = registration integrity. It also restores the only periodic log line a healthy idle node emits. Every other line is failure-conditioned, so external supervision was left with pid presence — which a parked process satisfies — and absence of errors, which is not evidence of health. The "fetching" line is emitted unconditionally and before the fetch, and is marked load-bearing in a comment so a future cleanup does not quiet it for noise. Quieting success lines is how this class of bug goes unnoticed. Teeth: the cursor clamps behind an unsettled delivery and fails closed on a read error (pure); and over real rows, a delivered job with no receipt pins the cursor until its receipt lands. Money suite: 501 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Delta 4 @0fece647 — restore the periodic payment-wrap backfill (money-path regression in this PR)Found by asking a narrow observability question — does a healthy idle seat emit any periodic log line? The answer was no, and chasing why turned up something worse than a missing log line: this PR had dropped a money-path safety net. The regression
Why main carried it, from its own comment (
So on this branch a delivered job's payment could sit uncollected indefinitely, with no restart-free recovery. The seat looks healthy throughout: it claims, delivers, heartbeats, and answers every REQ. This is a failure the liveness probe added in delta 1 cannot see — the session still serves our REQs, so the relay is alive by every measure the watchdog has. Worth stating plainly because it is the same shape as everything else in this PR: a check that looks like it covers the case, and doesn't. The fixA 300s tick that resolves a cursor, re-fetches stored wraps, and routes every one through the normal The cursor is the subtle part. It is the last collected receipt, clamped to the oldest delivered-but-unpaid job minus a skew margin. Last-receipt alone is wrong: a receipt for a NEWER job advances the cursor past an OLDER unsettled job and skips its still-uncollected wrap forever — the exact payment-stranding this exists to prevent. A store read error aborts the cycle rather than falling back to The operator tick, and why it's marked load-bearingAnswering the original question for the record: before this delta, a healthy idle node emitted nothing after boot. Every run-loop line is failure-conditioned — The backfill's "fetching" line is emitted unconditionally, before the fetch, and is commented as load-bearing for external supervision so a future cleanup doesn't quiet it as noise. It's a meaningful tick rather than a heartbeat-to-stderr: it says "I just re-asked the relay for payments", so it's a response, not a claim about ourselves. Quieting success lines is how this entire class of bug went unnoticed in the first place. Three layers, deliberately not mergedStated explicitly so a later cleanup doesn't "simplify" one away — none of these subsumes another:
The backfill is a workaround, not a diagnosis: it restores the payment without ever telling an operator a subscription died, and it only covers kind-1059 — a dropped offer or award subscription still has no repair. That's #172's job, and I've noted there that it should prioritise the work legs now that the money leg has a net. Correction posted to #172I had written that the reconciler's footing was "real but unobserved". Wrong — main's comment above records the class happening, with a stranded payment. Footing is stronger than I stated; corrected at issuecomment-5086679778. Gates — frozen tip
|
|
Four-delta acceptance-block closer — live receipt chain on the frozen final tip
This tip carries all four deltas: accept-replay execute-idempotency, the satisfiable liveness probe + proven reconnect re-auth, the structural park fix (off-runtime git + bounded signer + multi-thread runtime), and the wrap-backfill port with the cursor clamp pinning the stranded-payment class. Field-validated twice (dual validation on the reproducing host + this end-to-end trade). |
…ursor The backfill's log line is the only periodic output a healthy idle seat produces, so it is what external supervision watches — pid presence is satisfied by a parked process, and absence of errors is not evidence of health. The existing teeth guard what that line SAYS (cursor clamping, fail-closed on a read error). Nothing guarded that it happens at all. That gap matters because the obvious future optimisation — skip the fetch when the store has nothing outstanding — would silence exactly the healthy idle seats an operator least suspects, and put supervision back on absence-reasoning. A comment saying "load-bearing" does not survive a refactor; a test does. Asserts at the wire rather than in the log: the fixture's QueryPolicy counts kind-1059 REQs, so a skip-when-empty guard cannot pass by keeping the eprintln and dropping the fetch. Drives the real `SellerNodeRunner::boot` path against an in-process relay, so what is covered is the deployable shape rather than a hand-assembled runner. Verified by revert: adding the skip-when-empty guard to run_wrap_backfill fails this test with rc=101. Money suite: 502 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Delta 4b @28011f13 — pin the backfill's unconditionality, not just its cursorA second-order gap in delta 4, raised from the field and correct: delta 4's teeth guard what the backfill's log line says (cursor clamping, fail-closed on a read error). Nothing guarded that the fetch happens at all. That distinction carries weight because of what the line is for. It is the only periodic output a healthy idle seat produces, so it's what external supervision watches — and the obvious future optimisation, skip the fetch when the store has nothing outstanding, would silence exactly the healthy idle seats an operator least suspects. Supervision goes straight back to absence-reasoning, which is where this whole PR started. Delta 4 marked the line load-bearing in a comment. A comment does not survive a refactor; a test does. The tooth
It drives the real One note for whoever runs it: the test takes ~20s. A plain Bite: add Gates — frozen tip
|
|
Closing per gudnuf's V1 process ruling (2026-07-27): dev is the single integration line — no parallel open-for-main PRs. This PR's content is fully integrated into |
seller-node cutover:
mobee sellboots the durable seller nodeMakes the durable seller node (
crates/mobee-core/src/seller_node/) the real path formobee sell: a sqlite store + outbox +reconcile_on_startreplace the in-memory single-flight lifecycle. One process, one identity, restart-safe. The superseded in-memoryseller_daemonis deleted — cleancut: the code reads as if the node were always the seller path (no transitional shim, no cfg-gated fallback).Off the phase-1 checkpoint; rebased onto
origin/main(includes the #165 telemetry-flake fix). Diff is cutover-only (no drive-by refactors), net ≈ −6.3k lines. 8 commits, each green at its tip.Invariant → tooth
restart_then_award_rebinds_the_parked_claim(#154) kept green through the wiringresume_redelivery_is_deterministic_and_never_double_publishes+ seller_gittooth_delivery_snapshot_uses_journaled_datedeliver_and_enqueuededup → red (rc=101)redeem_classification_finalizes_and_never_forges_from_a_breadcrumb+seal_sender_must_be_the_bound_offer_buyer+realized_mint_must_be_in_the_stored_creq+receipt_collect_dedups_a_replayed_paymentawarded/executingjobs at boot:resume_selects_awarded_or_executing_not_delivered_or_terminal+boot_resume_re_drives_awarded_undelivered_job_delivery_lands_onceopen_claimspreserved)start_buzz(); the node never contacts buzz, so a down buzz relay cannot brick bootdelivery_preimage_binds_stored_creq_not_drifted_config+ unitdelivery_hash_binds_stored_creq_not_drifted_configWatchdog preserved (#150 + #162)
The #150 relay-stall watchdog is ported verbatim: own-heartbeat (kind-30340) each heartbeat tick; if no own heartbeat round-trips within
interval*missed_intervals, disconnect → reconnect → re-NIP-42 → resubscribe-all withsince=overlap(last-good −60s). Subscriptions cleared only after a successful reconnect+auth (never reconnected-but-deaf). #162 folded in: connect-phase failure retried 3× short-backoff within one recovery; success logsRELAY-STALL recovery SUCCEEDED (attempts=N, outage=Ss). Ported verbatim:stall_threshold_secs,subscription_stalled,self_heartbeat_subscription_filter,STALL_OVERLAP_MARGIN_SECS. Tooth:watchdog_stall_math_clamps_and_trips_only_at_threshold.Custody
Seller identity key stays at two sites (signer actor + authenticated relay client). Push NIP-98 auth, receipt-hash signing, and the gift-wrap NIP-44 decode all route through the signer actor (
http_auth_header,sign_receipt_hash,unwrap_payment_wrap). The cdk P2PK receive needs the raw cashu key (no signature-only path); it is derived inside the actor from the seller key and exported only as the derived payment secret — the identity key never leaves the actor.Follow-up (recorded): move the cdk
receiveINSIDE the wallet actor so the wallet actor owns the cashu key entirely (actor-to-actor handoff, nothing exported). Deferred out of this slice.Follow-up: ACP
session/loadresume (resume the agent's own session vs a fresh re-run + workdir inspection) — invariant 4 is shipped in its fallback form here.Gates (rebased tip)
-p mobee-core --no-default-features --features gateway,git-delivery,wallet --lib): 490/0 — release and debug (rc=0)cargo test -p mobee-core, CI build-default): 162/0 (rc=0)cargo build -p mobee-core --features acp,gateway,git-delivery,wallet): rc=0cargo build -p mobee --release --features acp,wallet, CI build-acp): rc=0origin/main's own untouched files (e.g.budget.rs) failfmt --checkandclippy -D warningsunder the pinned toolchain, and CI enforces neither. No reformat (would be a 56-file drive-by). New code is fmt/clippy-clean (onetoo_many_argumentsallow ondelivery_receipt_preimage, matching the store's existing allow). Repo-wide fmt/clippy+toolchain-pin+CI is filed as its own chore.Live smoke — PENDING
Testnut end-to-end through the node path (post→claim→award→deliver→receipt), receipt id to be posted as a PR comment. Prereqs verified: relay
mobee-relay.orveth.devreachable, testnut mint reachable,claudeagent available (via npx). Running against fresh throwaway homes.🤖 Generated with Claude Code