buyer: settle delivered work automatically, and free reservations a seller abandoned - #181
Merged
Conversation
Both legs of a buyer signer round-trip were 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. Neither arms a timer, so the runtime has nothing to wake for — a caller reached from a long-lived task's `select!` branch body is simply never polled again, at 0% CPU, with nothing logged anywhere. That is the diagnostic filter, too: a permanent park proves no timer was pending, which excludes every bounded await and leaves exactly these channel awaits. Both legs now route through one bounded `round_trip`, and the error names the call and the leg so an operator sees which round-trip stalled instead of a bare "actor gone". A bound cannot make a stuck actor answer; it converts an invisible permanent park into a named, recoverable failure at the exact site. The tooth runs on paused time, so the production bound elapses instantly in wall-clock, and the outer timeout makes a revert fail cleanly rather than hang the suite. It calls twice: the second proves the caller is left usable, which is the property a long-lived daemon task actually needs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The buyer had no persistent relay presence. Every publish, every has_award, every job-view fetch built a fresh Client — fresh MemoryDatabase, fresh WebSocket, fresh NIP-42 handshake — did one thing, and disconnected. That also makes get_job(wait_for) a reconnect-per-iteration poll where a subscription is the correct primitive, which is the dominant term in trade latency. The session is an actor, not a shared Client, because the money write does not run on the daemon's runtime: CdkPaymentEffects builds a current_thread runtime on its own OS thread and awaits the payment gift-wrap there. Channels cross runtimes; Clients do not. So the client lives in one task and every caller reaches it through a handle whose both legs are bounded — an unbounded write actor would reintroduce the signer-park class at a brand-new site. The write path does NOT retry an auth-required rejection. Relay::send_event already waits for authentication and resends. Layering our own retry on top would make ours the third publish of the same event and blur which failure the caller sees. That resend is, however, silently conditional on auto-authentication being enabled, so it is set explicitly and a tooth pins the contract: the same write lands with it on and stays refused with it off, so an SDK bump or an option-drift cleanup goes red instead of quietly removing the retry. An empty accepted-set is a failure, never a quiet success: send_event_to returns Ok even when the relay refused, with the reason in output.failed. Reading only the outer Result would report a refused write as published, and on the money path "we think we sent it" is the ambiguity this daemon exists to remove. spawn returns as soon as the relay is registered, and the actor performs connect and NIP-42 as its first action. The daemon binds its socket after bootstrap and connect-or-spawn allows a cold daemon 10s to appear, so a handshake bounded at 20s on the startup path would let an unreachable — or merely lazily-challenging — relay push the socket past that deadline, and every MCP call would report a daemon that failed to start while it was coming up fine. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
get_job(wait_for=...) slept 400ms and then rebuilt a Client, reconnected, and ran three sequential fetches — every iteration. One ten-second wait cost up to twenty-five fresh WebSocket dials, and a delivery was discovered on the next poll window rather than when it landed. That is the dominant term in trade latency. The subscription is buyer-keyed, not job-keyed. Claims, results and feedback all p-tag the buyer, so ONE static filter covers every job this buyer has or will ever have: no per-job REQ to open when a job is posted, none to close when it settles, no filter to rebuild on reconnect. A dynamic e-tag list would be a subscription-lifecycle problem taken on for no benefit. The subscription is the WAKE; the fetch is the TRUTH. An arriving event means only "something changed for this job" and the view is re-read from the relay. Assembling the view out of the event stream would duplicate the assembly and force trusting a stream that may be partial; this way a missed event costs a re-check, never a wrong answer. A three-second backstop bounds how long a wait can be wrong when an event never arrives at all. The poll variant is untouched, because the CLI has no persistent session to ride. What the two share is the one thing that must never diverge: view_is_ready decides for both. The mechanisms differ deliberately; if the definitions of "ready" ever differed, the same job would read complete to the daemon and pending to the CLI. Lagged is not an error. It means this receiver fell behind and events were dropped for it — so something happened, and the answer is to re-check. Treating it as a failure would turn a busy relay into spurious timeouts that look like a relay fault. Commands are served by a sibling task of the event forwarder: a publish may occupy its full timeout, and if that ran in the forwarding task no job event would reach a waiter for the duration. The job REQ is re-asserted after auth on boot and on every reconnect rather than trusted to replay, because an unauthenticated p-filtered REQ can be CLOSED as restricted, which the SDK treats as removal — leaving a subscription that is gone while the socket still looks healthy. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…sked An awarded job used to sit until someone ran collect by hand. The daemon now watches for delivered results and settles them itself, so a seller is paid in seconds rather than whenever the buyer next looks. The watcher adds no money authority. It commits no new money; it converts a reservation the award already created. Both the collect RPC and the watcher go through one `settle_job` — money lock, budget gate, pay-then-flip — so there is a single path to the spend gate rather than two that can drift. Every gate it relies on already lives inside the sealed pay path: the job-hash recomputed from the buyer's signed offer, the offer-authoritative amount, the seller co-signature, the creq check, single-settlement, and the pre-spend commit tip-match. Adding a second verification layer here would be a second thing to keep in agreement, so there isn't one. What the watcher may settle is read from durable state, never from the event that woke it: an arriving result only says "look again", and optionally narrows which job to look at. The work set is derived — an award whose reservation is still `reserved` — so no second lifecycle can disagree with the ledger the budget already trusts. The loop sweeps before it ever waits, which is what collects a delivery that landed while the daemon was down. Awards are recorded where they are published, at the one reserve-then-award seam both the manual and background paths reach the relay through. A store failure there does not fail the award: the event is already public and the reservation already held, so reporting failure would be untrue and would release committed funds. It says what the operator lost instead. The backstop is a fixed-cadence interval rather than a timer re-armed each pass, because a re-armed timer is pushed back by every arriving event — including the ones the watcher ignores — and would go quiet under exactly the steady traffic it exists to survive. Losing the subscription degrades to periodic sweeps rather than stopping; settling never depended on that subscription. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A seller that simply stops leaves the buyer's funds committed to a job that will never deliver. The reconcile that resolves this only ran at startup, so the budget stayed pinned until someone restarted the daemon. It now also runs on a slow timer, which frees the reservation within the hour instead. Nothing new decides what may be released. The existing classification already rules it — a paid job converts, an ambiguous payment is never auto-released, and a job is dead only when no payment left funds and the relay no longer shows it payable. That last condition is already time-based rather than dependent on the seller announcing failure: a claim stops being live at the offer deadline whether or not any feedback is ever published. A fresh timeout would have had to re-derive all of that, including the ambiguous-payment care that matters most. Gathering runs without the money lock, since it is per-job relay I/O and would otherwise block trades for seconds; only the apply takes it. That ordering is what makes it impossible to release a reservation in the window after a payment melted but before its reserved-to-spent flip, which would briefly free funds that had already left the wallet. The apply is state-guarded inside its transaction besides, so a disposition that went stale while gathering is a no-op. Every pass is now reported, including the pass that changed nothing. A release moves what the buyer can spend, and a path that prints only when it acts is indistinguishable in a log from one that has stopped running. Released jobs are named, and the examined count is in the line because it is also the number of per-job relay fetches the pass made, so that cost is visible while it grows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…it does The relay session carried a reconnect command that dropped the socket, re-authenticated, and re-asserted the job subscription in that order. Nothing called it. Every reconnect in the field was always the SDK's own, so that code was not a fallback — it was an untested assumption standing where a verified one should be. What actually happens across a relay restart, measured on a NIP-42-enforcing fixture: the buyer's pubkey-scoped subscription IS refused, because the pool re-issues the request before authentication completes and the relay answers `auth-required:`. That prefix is retryable, so the subscription is kept rather than discarded — the SDK authenticates and re-asserts the same subscription id, and delivery resumes. Re-subscribing ourselves on top of that would be a second request racing the SDK's, which is the same hazard as publishing an event a dependency is already resending. So the command is gone and a test pins the behaviour being relied on instead. It publishes the counterparty's delivery to the replacement relay without waiting for our own session to recover, so the event lands while the subscription is still closed. Passing therefore proves recovery replays what was published during the outage, which is the case that matters: a relay restarts, the seller delivers, and the buyer must still see it. A test that waited for recovery before publishing would have proved only its own timing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The money lock the reconcile takes for its apply had no test. It closes a real window — a pass whose gather decided a job was dead must not act on that after a settle has melted but before it flips the reservation to spent, or funds that already left the wallet get counted as available again. The assertion is on the reconcile's own report rather than the resulting ledger state, and that distinction is why this was missing rather than merely untested: the end state converges either way, because a released row that turns out paid is converted by the next pass. Any test asking what the ledger looks like afterwards passes with the lock removed. The decision is where the bug is visible. It also asserts up front that the job really does classify as dead, since a job that was never at risk of release would satisfy the rest of the test for no reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
Author
|
Gate: CODE-GATE PASS on 26c523b (both bounce items closed with production-line red-on-reverts re-run at the gate; #176 SDK reading verified against nostr-relay-pool source; 4 money/liveness reverts re-proven; all gate combos 0-failed). Merged to dev for integration testing per the rolling model — stays open for main review. Dogfood latency receipt joins when the seller seat wakes. |
This was referenced Jul 27, 2026
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.
The seller is paid in seconds, automatically, and a job that never delivers stops holding the buyer's money hostage.
Today an awarded job sits until a human runs
collect, and a reservation left by a seller that simply stopped is only freed by restarting the daemon. This wires the other half of the trade loop: the buyer watches for delivered results and settles them itself, and it keeps reconciling while it serves.The sentence that makes auto-spend reviewable
The watcher commits no new money; it converts a reservation the award created.
It pays only inside an envelope the buyer already reserved, for a job the buyer already awarded. That is why this is not a new spend authority — and it is the claim the rest of this description is here to substantiate.
The decision I most want reviewed: the verify predicate is INHERITED, not rebuilt
The slice was chartered to add an artifact predicate before any pay — commit-id + job-hash binding for every job, exact content where the job spec pins output. I did not build one, deliberately. Every part of it is already enforced in-tree, upstream of every spend:
job_lifecycle.rs, accept path) — a check, not an echo of the seller's number;bind.commit_oidruns insideauthorize_pay, before any spend;ContentPolicy, already applied inauthorize_pay, with afloor()default when the spec pins nothing.A second predicate here would be a second money-authority surface that can drift from the sealed one. So instead of new checks, this PR proves the watcher-triggered entry reaches the existing ones (see the money tooth below). Please review this as a decision, not as an omission — if you disagree, the right argument is "the inherited gate is insufficient because X", and I'd want to hear it.
One path to the spend gate
The collect RPC's whole body — money lock → budget gate → pay-then-flip — is lifted into a single
settle_job. The RPC renders JSON over it; the watcher calls the same function. The daemon now has exactly one route to the spend gate, so "can the watcher reach around a money gate?" is answered by construction rather than by audit. Error mapping is preserved exactly.The subscription is the wake, the fetch is the truth
An arriving result event is never evidence. It only says "look again", and optionally narrows which job to look at. What may be paid is read from durable state — an award whose reservation is still
reserved— and decided entirely by the sealed pay path. A forged, replayed, or malformed result costs at most a wasted fetch.The work set is derived (
awards ⋈ reservations WHERE state='reserved'), not tracked as a new state, so no second lifecycle can disagree with the ledger the budget already trusts.The award record itself is written at the one reserve-then-award seam both the manual and background award paths publish through — recording at the two call sites would let one drift. A store failure there does not fail the award: the event is already public and the reservation already held, so reporting failure would be untrue and would release genuinely committed funds. It names the consequence instead.
The boot re-check is the watch loop's first action. It sweeps before it ever waits, because a delivery that landed while the daemon was down has no event to replay — that sweep is the collect-saga boot pass the charter asked for.
Two defects I found in my own loop, fixed and toothed
sleepinside theselect!, so the backstop reset on every arriving event — including the ones the watcher ignores, which reset it without sweeping. Steady traffic would have starved it indefinitely, killing it in exactly the case it exists for: a result event that was missed. Now a fixed-cadenceinterval.Both are the "liveness mechanism silently dead while everything looks fine" class, and correctness teeth are structurally blind to them — so
watch_looptakes its sweep action as a parameter and both are tested with an injected counter: no relay, no clock control.Freeing an abandoned reservation
The reconcile now runs on a slow timer as well as at boot. No new logic decides what may be released — the existing classification already rules it, including the part that matters most, that an ambiguous payment is never auto-released. It is already time-based rather than dependent on the seller announcing failure: a claim stops being live at the offer deadline whether or not feedback is ever published.
Gather runs without the money lock (per-job relay I/O would block trades for seconds); only the apply takes it. That ordering makes it impossible to release a reservation in the window after a payment melted but before its
reserved → spentflip.Every pass is reported, including the pass that changed nothing — a path that prints only when it acts is indistinguishable in a log from one that has stopped running. Released jobs are named, and the examined count is in the line because it is also the per-pass relay-fetch count, so #180's amplification is visible while it grows.
The #176 chain, in full
#176 was escalated to money-path mandatory on the strength of a relay-operator confirmation, requiring the buyer to hand-roll an auth-and-resend on the payment write. I corrected the premise at the source and then reversed the change. The chain, so the gate reviews a decision rather than noticing an absence:
Relay::send_event(nostr-relay-pool-0.44.1) already does auth-and-resend on theauth-required:prefix — the issue citedwait_for_okin isolation, one level below where the resend lives. Every precondition holds (prefix match, auto-auth default on, signer present, bounded auth + ok waits), and no silent-loss path exists:payment_sentreturnsErron an empty accepted-set. Hand-rolling ours would have been a third publish of the same event on a money path.What survives is the real residual:
automatic_authentication(true)stated explicitly as a drift guard against an upstream default flip, plus a contract tooth. Demonstrated on the wire — same write, auto-auth on lands, off is refused with the literalauth-required:string. Honest limitation: the SDK default is already true, so deleting that line changes nothing today; it guards the dependency boundary, not local deletion.Named residue — not silent
The charter said
drive_auto_awardwould become event-driven. It did not. The only production consumer of the event-driven wait is theget_jobRPC'swait_for;drive_auto_awardstill polls on its interval. That is a claim-wait (3402) — a different consumer from the delivery watch — and folding it in would widen a money slice for latency, so it is filed as a follow-up rather than done here.Also filed from this work: the boot-path per-job fetch pattern (#180), which the reconcile cadence is currently holding down rather than fixing.
Evidence
Baseline on the frozen base tip, then per piece: applied with count reconciliation, red-on-revert with the named failure, restored clean.
spent→ derivation tooth redreturnon a closed subscription → degrade tooth redBursttick behaviour → green, as predicted: recorded because it states honestly what the no-overlap tooth does not cover — overlap is structurally impossible in a single-task loop that awaits its pass, so no compiling mutation can produce itThe money tooth deserves its own note: every assertion in it is a "nothing happened" assertion (zero spend, no journal, no files, reservation intact), so all of them would pass if the watcher had never woken at all. The anchor is that accept writes the pay-bind before the pre-pay co-signature gate refuses — so the bind file existing is positive proof the watcher really drove that job. Counterparty events are published from a separate client, because one nostr-sdk client cannot observe its own events and that trap bites fixtures exactly as it bites production.
No live spend has been run. The dogfood trade is held pending explicit sign-off.