feat(protocol): split ACCEPT onto kind 3406, so a pay-bind is not an award - #329
Merged
Conversation
…award AWARD (3405) selects a claim before work. ACCEPT binds payment to a verified result after delivery. They are different statements, and while they shared one kind the only way to tell them apart was to count a job's events — which is not a discriminator, because two events of one kind is also what a re-publish looks like. Two 3405s per job was the documented NORMAL steady state. Emit prepare_award_async -> award_draft, kind 3405 (unchanged; the selection) accept_claim_async -> accept_draft, kind 3406 (the pay-bind) Both call sites were the byte-identical string `let draft = award_draft(`, so the edit was targeted by surrounding context and the enclosing function of each re-derived afterwards. Inverting them would move awards to 3406 and leave accepts on 3405. gateway gains accept_draft and parse_accept. The shared offer/claim e-tag shape is extracted to one private helper — the two events carry identical tags and differ only by kind, so duplicating it would be one fact in two places. parse_accept is a separate entry point rather than a widened parse_award: a caller asking "is this a selection?" and one asking "is this a pay-bind?" must not be able to satisfy each other by accident, which is the failure the shared kind produced. Seller: subscribes to 3406 on the existing AWARD REQ (kinds([AWARD, ACCEPT]), not a new sub id — a second subscription is a second thing that can die quietly) and dispatches to on_accept, which binds-if-unbound and NEVER executes. Bind-if-unbound is a real precondition: record_award is unconditional once entered and does UPDATE claims SET state='awarded', which for an already-delivered job would regress a terminal claim row. So job_award_time is read first and a write happens only on None — the across-restart re-bind of TOOTH 3 (#143), which worked by accident while the kinds were shared and would otherwise have narrowed silently. No replacement double-award guard is owed, and this is measured rather than assumed. record_award dedupes on award_id (PRIMARY KEY) while job_id has no unique constraint, so the accept-3405 inserted a second awards row, returned Awarded::New, and on_award spawned execute_job a second time. That was contained by execute_job's own state guard (should_resume_execution admits Awarded and Executing only, red-on-revert at run.rs:4405) — a guard the code documents as catching "a REDUNDANT award ... seen live in the smoke". The split deletes the redundant trigger; the guard stays load-bearing for its restart-resume caller. The second awards row also made job_award_time (no ORDER BY) depend on row order, and that value is the delivery commit's authored-at, so removing it makes byte-identical re-delivery deterministic rather than incidental. Readers, from measured site lists rather than a symbol sweep: both web registries independently — network/js/kinds.js and app/js/kinds.js have drifted, and app carries TRADE_STAGES that network does not. Missing MOBEE_TAGGED_KINDS would mean the REQ never asks for the kind, rendering as silent "no activity". mcp.rs needs no edit: its single 3405 mention is the AWARD tool's description, which the split leaves correct. docs/protocol.md moves with the wire it describes. Its step 6 claimed the buyer "records the local pay-bind" with no published event, which was already false before this change — accept published a 3405. The split is what makes shipped behaviour describable. Out of scope and untouched: the #t value, PROTOCOL_VERSION, the rename. Verified with cargo check -p mobee-core --all-features --all-targets (0 errors) and cargo test --all-features --lib: 717 passed, 0 failed, 3 ignored. Default features are not a check of this crate — the wallet gate hides seller_node, buyer and job_lifecycle, confirmed by an injected type error passing under default features and failing under --all-features. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
… 3405 Eight comment sites in job_lifecycle.rs stated a wire shape the split changed. Four are money-path: the accept emit kind on `accept_claim`, the module doc, and the two "multiplicity is the normal steady state" blocks that this change's own thesis kills. `award_presence_async` filters on `JOB_AWARD_KIND` alone, so with the accept on 3406 every event that read returns is a selection. Its reconciliation stays, and the comment now gives the reason that survives: the read reports what the relay holds for a ledger that may be missing a row, so its soundness must not rest on the emit discipline it is checking, and refusing on count alone would refuse to repair a row precisely when that row is most likely absent. The 3405 references that name the AWARD are unchanged, each checked rather than assumed: the signed attempt (#322), the absent amount tag, the re-arm presence read, and the disagreeing-awards red leg all describe kind 3405 correctly. Two test literals move with their comment — `the-award`/`the-accept` become `the-earliest`/`the-later`, since the pair that reaches `reduce_parsed_awards` is two selections, not an award and its accept. Also: an ACCEPT event reaching the app parsed to `null` and was dropped by the stage filter, which renders as a trade stalling at award with no error rather than as an accept. AWARD and ACCEPT are both buyer-authored with the same tags, so ACCEPT joins the AWARD arm and the stage mapping it already had becomes reachable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pmilic021
added a commit
that referenced
this pull request
Aug 1, 2026
…ew rounds (follow-up to #328) (#334) * review round 2: unwedge the money lock, gate every terminal write, and probe with typed refusals Five reviewers (1 Fable + 4 Opus) against round 1; all five converged on two CRITICALs in the round-1 sweep machinery, one empirically reproduced each way. The deadlock (4/5 reviewers, one with a tokio repro): the manual award RPC held money_lock across the past-deadline resolution, whose Present arm re-locks the same non-reentrant mutex — the fix's own documented retry walked an agent into a daemon-wide money freeze. The lock is now taken AFTER the pinned-attempt handling; every resolve_expired_attempt caller is lock-free, and the chokepoint helper owns its own guard. The unguarded terminalizer (2 reviewers, six-step interleaving traced): the expired-attempt refusal wrote refuse/release/park unconditionally, outside the lock — a racing Present-resolver's freshly re-held funds could be stripped from a recorded public award, invisible to every heal. The marks now RETURN whether the pending→terminal transition took; the release and park are licensed by winning it (terminalize_absent_attempt, pure over the store, exhaustively tested), applied under money_lock. Terminalization got two stronger guards (money-state + wire reviewers): a delivery probe — any kind-3403 for the job is positive evidence the award WAS public and merely aged out of the probe's view, so refusing would repudiate work that happened — and the probes now fetch through the single-relay API, where a relay REFUSING the REQ surfaces as a typed error (Unverified) instead of being swallowed into the emptiness that licenses a release (the pool API's Ok(empty) covered CLOSED-with-reason and auth failures; the EOSE proof also vouched for a different filter than the read). Sweep topology (concurrency reviewer): the boot pass now runs as the first act of the reconcile task — ONE task, so two sweeps can never overlap — and pre-deadline re-sends happen OUTSIDE the money lock (the durable send-count license is taken by the sweep; the chokepoint folds in the verdict via a replay closure), cutting worst-case lock tenure from 65s of relay I/O per attempt to milliseconds. The send's connect-wait moved inside the 45s budget. Reconcile now SKIPS jobs with a pending attempt — their funds are deliberately held while the verdict is open — killing the per-tick release→re-reserve flip-flop and its freed-capacity stranding race. New finishers close the crash windows reviewers enumerated: refused-but- still-reserved attempts get their release completed through the chokepoint's RefusedTerminal arm (a work set that previously appeared nowhere), and the auto path's deadline arm now finishes Confirmed/Refused attempts through the chokepoint instead of parking a false "no awardable claim appeared". Migration hardening: pre-column rows backfill send_count=1 (0 is the license to treat OK:false as proof — assuming zero re-opened the burn for exactly the migrated population) and the '' relay sentinel now falls back to live config (it could neither send nor probe, holding funds forever). Plus: the manual expired path reports the confirmed-but-unrecorded state truthfully (collect guidance) instead of "still unresolved"; the PresenceUnverified recovery text matches what actually re-probes; pre-send guards (pinned-id + signature verification) got their free in-process tests; and the pay-window boundary, the refusal-transition license, both new work sets, and the relay fallback are all pinned by new tests. mobee-core: 721 passed / 1 failed (pre-existing darwin /proc test, green on Linux CI); bins 35/35. +10 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review round 3: authenticate the delivery guard, close the deadline TOCTOU, and give every gap its tooth Four reviewers (1 Fable + 3 Opus) against round 2. Both round-2 CRITICALs verified fixed (zero lock-nesting paths; the gated terminalizer holds under every constructed interleaving; all round-2 headline tests red-on-revert). This lands everything the round surfaced. The delivery guard is now authenticated (2 reviewers, MAJOR): the pre- terminalization 3403 probe filtered by kind+job only, so ANY pubkey could publish one junk result and permanently pin the buyer's funds — reconcile- immune by our own design, no exit, and the log's own remedy (collect) impossible against a forged result. The probe now requires the PINNED seller as author: only the awarded seller's delivery is evidence our award was public. The terminalizer gained the awards-row guard (MAJOR): a pending attempt WITH its row (the confirm-write-failed crash) could be refused past the window and its RECORDED award's funds released. The row is written only on an ack or a presence-verified repair, so its existence outranks any probe emptiness — mirrored now in terminalize_absent_attempt's first read, with the test case to pin it. The deadline no-transmit gate lost its TOCTOU (MAJOR): round 2's deadlock fix moved the money lock below the deadline check, so a long lock wait (unbounded behind a settle) or an attempt pinned mid-flow could cross the deadline and ResumeAttempt re-sent without re-deriving it. Both send sites re-check under the guard (pure predicate, boundary-tested) and bounce to the probe path. The Present-arm acts on its transition bool (MAJOR): losing pending→refused to a concurrent diverging resolver while holding positive proof the award is public is an unhealable one-way divergence — now surfaced loudly with the collect action, never logged as a retryable heal failure. The sweep's license section moved under a brief money-lock scope (Fable): re-read-still-pending + fund + count, milliseconds under the guard, then the 45s transmission outside it. That restores the serialization premise the prior==0 refusal license rests on (no concurrent copy beside a first transmission), puts funding back before the bytes, and skips transmission entirely when the awards row already exists. drive_send's release is likewise licensed by its own mark now. Intent truthfulness: a pinned attempt outranks offer-shaped park reasons on BOTH the deadline and the missing-offer paths (settle_intent_from_attempt — round 2's finisher arms were dead code for their main population, parked as "offer absent" by the boot re-arm); the heal leg clears stale parks; the finisher leg stamps the real refusal detail; the Confirmed arm marks awarded only when the heal landed and parks the unrepairable case exactly as finalize does. Visibility: status gains `pending_award_attempts` — a held reservation was invisible everywhere but stderr, leaving "why is my available low?" unanswerable; manual-path attempts have no intent row so parked_awards alone could never cover them. Tests (+5, per the test-rigor reviewer's surviving-mutant list): the reconcile skip wiring (premise-checked Dead), the migration backfill (send_count=1 + '' sentinel, pre-column seed), the expired-RPC triple tooth (timeout-guarded — a reintroduced lock nesting wedges it; also pins the expired-branch response and the ConfirmedAbsent-past-window arm end to end), the sweep wiring (all three legs + the inside-window hold with zero transmission), and the TOCTOU predicate. mobee-core 726 passed / 1 failed (pre-existing darwin /proc test); bins 35/35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review round 4: close the sweep's deadline TOCTOU and make the send license single-count Three reviewers (Fable convergence + Opus money + Opus test-rigor in an isolated worktree). Fable's verdict was NOT CONVERGED, and it was right: round 3 fixed the deadline TOCTOU at two of the three live send sites and missed the sweep's own license section, which has the identical pre-gate → lock-wait → transmit shape. Opus money found the same MAJOR independently. - The sweep re-checks the deadline under its guard (the round-3 predicate, now used at all three sites): a deadline that crossed while waiting on a settle-held lock sends NOTHING and the attempt resolves by probe. Without this the seller executes a late award unpaid — the #322 harm. - The transmission license is COUNTED ONCE: the sweep's prior count rides to the chokepoint instead of drive_send re-counting. Double-counting pushed a genuinely-first transmission to prior==1, so a deliberate relay refusal of it held the funds for the whole 7-day pay window instead of releasing at once — and it over-reported send_count on the status surface round 3 added. - The license's money snapshot moved INSIDE the guard: the two-ceiling check must not decide on balance/spent numbers a concurrent melt has already invalidated. Every other reserve site in the file already held to that; this leg's population (a released+pending row) is exactly the one that reaches the real ceiling check. - The manual RPC re-derives the pinned-claim conflict from its fresh under-lock read: an attempt pinned to claim Y while the call was queued no longer silently resolves for a caller that named claim X. Tests (+3), all verified red-on-revert against true reverts of committed guards in an isolated worktree: a third party's junk kind-3403 cannot hold the refund (the anti-griefing author filter had no tooth — removing it was fully green); the deadline TOCTOU re-check is WIRED, not merely predicate-tested (deleting both round-3 blocks was fully green), with real signed bytes so its no-transmit assertion is load-bearing rather than vacuous; and a carried license keeps the first-transmission refusal immediate. The round-3 mutation pass also confirmed 8 of 10 targets already had teeth, including the round-2 deadlock dying by timeout as designed. mobee-core 729 passed / 1 failed (pre-existing darwin /proc test, green on Linux CI); bins 35/35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review round 5: reconcile the carried license against the wire, and wire the guards that had none Three reviewers (Fable convergence + Opus money/concurrency + Opus test-rigor in an isolated worktree). Fable and Opus independently found the same MAJOR — a hole in round 4's own fix — and the mutation pass found that two round-4 guards had no wiring test at all. The carried send license was license-order truth, not WIRE-order truth (MAJOR, 2 reviewers): the sweep licenses with prior=0, drops the money lock, then transmits for up to 45s. In that window an RPC retry can license AND transmit its own copy of the same bytes under the lock — and that copy may have LANDED with its OK lost. A deliberate refusal of the sweep's copy then passed the `prior == 0` gate, terminalized, and RELEASED funds for an award that is public: #322's exact harm, in the direction round 4 claimed to secure. The round-3 soundness argument only covered the RPC-licensed-first order; the sweep holds nothing during its own send. drive_send now reconciles the carried license against the freshly-read row (`prior.max(send_count - 1)`) — the chokepoint reads the attempt under its own guard, so send_count counts every transmission ever started, including the concurrent one. A stale license can no longer license a terminal refusal; the verdict folds to a hold and the pay-window probe owns it. Also: an unrepairable confirmed attempt (award public, row unwritable because the balance shrank) now parks its intent. It is in no other status surface — `pending_award_attempts` selects only pending rows — so a seller owed money was invisible outside stderr. Wiring teeth for the two round-4 guards the mutation pass caught surviving (both tests written and verified by the reviewer, then re-verified red-on-revert independently): the sweep's under-lock deadline re-check now has its own LocalRelay test (deleting the arm transmits — send_count 1 vs 0), and the sweep's carried-license wiring is pinned inside the existing sweep test (passing None counts twice — 2 vs 1). Plus a tooth for this round's fix: a stale carried license after a concurrent transmission must fold to Unresolved and hold the funds. All three new teeth verified red-on-revert in an isolated worktree, with the timing-dependent relay tests stable across four runs. mobee-core 731 passed / 1 failed (pre-existing darwin /proc test, green on Linux CI); bins 35/35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review round 6: stop reconcile releasing a public award's funds, and tooth the guard's other direction Three reviewers (Fable convergence + Opus money + Opus test-rigor in an isolated worktree). Fable's verdict was CONVERGED on the money machinery — it verified the round-5 max is correct on every path into drive_send and that no hold is newly unbounded — so what remains is one real fund path Opus found plus the gaps the mutation pass exposed. Reconcile could release funds for an award that is provably public (MINOR per the reviewer, but it is #322's harm ledger): the skip set covered only PENDING attempts, so a CONFIRMED attempt whose awards row is missing (the crash window between the relay's ack and record_award) could be classified Dead and released. The row is written only on an ack or a presence-verified repair, so its existence proves publicity — and the sweep's heal is what re-reserves it. `pending_attempt_job_ids` becomes `attempt_held_job_ids` and covers both states; a confirmed attempt WITH its row is deliberately not shielded (the normal awarded state reconcile has always judged). status gains `unrecorded_confirmed_awards`: a public award whose row cannot be written was enumerable in NO surface — pending_award_attempts selects only pending rows, and on the manual path there is no intent row for parked_awards to carry, so a seller owed money was invisible outside stderr. The predicate already existed as the sweep's own work set. The heal leg now parks ONLY the unrepairable case (Fable): parking on any error meant a transient money-snapshot failure flipped awarded → parked, telling an operator "the award could not be placed" about a job whose award is public and whose seller is executing — an operator acting on that list could post a duplicate offer. Transients stay for the next tick, matching settle_intent_from_attempt's discipline. The anti-griefing test was ONE-SIDED (test-rigor, the round's sharpest find): it proved the delivery guard is not too permissive, but an INERT guard — one that never answers Present — satisfies its assertions identically. So the direction that repudiates a seller who actually delivered had no tooth at all. `the_pinned_sellers_delivery_holds_the_refund` is the mirror: the PINNED seller publishes a 3403 and the terminalization must hold. Verified to kill both the fall-through and the inert-probe mutation, while the junk test passes under the inert one — the asymmetry measured, not assumed. Two more silent writes got teeth: the heal leg's park (hermetic via an AmountMismatch repair) and terminalize_absent_attempt's park (its Case 2 had no intent row, so the write was a no-op there). All five round-6 mutants verified killed in an isolated worktree, restore clean, relay tests stable across two passes. mobee-core 733 passed / 1 failed (pre-existing darwin /proc test, green on Linux CI); bins 35/35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review round 7: shield the verdict not the job, and make the sweep-deadline tooth attributable Three reviewers (Fable convergence + Opus money + Opus test-rigor in an isolated worktree). Fable returned NOTHING-SIGNIFICANT — the first clean convergence verdict, with the round-6 SQL, the heal error set, and both headline invariants verified. Opus money verified items 1/3/4/5 clean and found one MINOR; test-rigor killed 8/8 listed mutants and then built one of its own that exposed a genuinely vacuous test. The reconcile shield was too broad (Opus money): `retain`-ing held jobs OUT of the batch suppressed reconcile's `Paid → spent` arm as collateral — and that arm is the ONLY converger for a pay whose `reserved → spent` flip failed (`settle_after_pay`'s documented recovery). A crash-then-manual- collect on a confirmed-without-row attempt could leave the amount counted in BOTH `spent` and `reserved`, understating `available` until the heal happened to un-shield it. Held jobs now stay in the batch and only their `Dead` verdict is downgraded to `Payable`, so the release decision is shielded and nothing else is. New tooth asserts all three cells: held+Dead keeps, held+Paid still converges, unheld+Dead still releases. The sweep-deadline tooth was CONDITIONALLY VACUOUS (test-rigor, the round's sharpest find). It pins `deadline = now + 2` and asserts two pure negatives (`send_count == 0`, `Pending`) — but if the sweep's prologue takes longer than the 2s margin, the PRE-lock gate fires first and diverts to the probe path, which leaves byte-identical state. The reviewer demonstrated it: deleting the guard AND adding a 3s prologue delay left the suite green. So the test degraded to green rather than red — the worse failure mode, and the opposite of what its sibling does (the `award()` twin asserts on the refusal MESSAGE, so a slip there goes red). A control job in the same sweep, far from its deadline, now proves the license section actually ran: its `send_count == 1` makes the crossed job's `0` attributable to the under-lock re-check rather than to a pre-lock divert. Two NITs taken: the unrepairable-park test no longer accepts `already published` (the Display wrapper present on EVERY PublishedButUnrecorded, so the disjunction did not enforce naming the cause), and a stale red-on-revert count is corrected. mobee-core 734 passed / 1 failed (pre-existing darwin /proc test, green on Linux CI); bins 35/35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review round 8: the round-7 "attribution" control did not attribute — use the award event as the discriminator Three reviewers (Fable convergence + Opus money + Opus test-rigor in an isolated worktree). Fable returned NOTHING-SIGNIFICANT for the second round running, having walked every reconcile arm for held jobs and confirmed `Payable` has no effect beyond "keep". Both remaining findings are about my own test claims being weaker than advertised — the most useful kind. CRITICAL (test-rigor): round 7's control job does NOT deliver the attribution its comment claims. `send_count` is per-attempt, so the control witnesses that THE CONTROL reached the license section — it says nothing about the crossed job, which is the only one that can divert. The reviewer re-ran the original mutant (under-lock arm deleted + 3s prologue) and the test was still green: the crossed job took the PRE-lock gate, the control sailed through, all three assertions held. So the conditional vacuity round 7 claimed to close was still open. The real discriminator is the award EVENT, not another job's counter: the pinned event is now published to the relay before the sweep runs, so a pre-lock divert probes it, finds it Present, and CONFIRMS the attempt with a healed awards row — visibly different from the under-lock refusal's Pending + no row. Verified both ways myself: mutant (d) (arm deleted) dies on send_count 1 vs 0, and mutant (e) (arm deleted + slow prologue) now dies on Confirmed vs Pending where it previously passed. The test fails in both timing regimes instead of going quietly green. The control job stays — it is the only tooth for the sweep's carried-license wiring. MINOR (Opus money): the round-7 tooth could not bite the round-7 defect. It is a `plan_reconcile` unit test with hand-built inputs, while the defect lived a layer up in `reconcile_reservations`; the reviewer demonstrated a reinstatement (`reserved.retain(|j| !attempt_held.contains(j))`) that keeps every test green. The end-to-end test now asserts the held job is KEPT — a positive claim that dropping it from the batch cannot satisfy. Verified: the reinstatement fails with `kept: []`. Docs corrected where they still described the pre-round-7 total shield: `attempt_held_job_ids` now says it owns the RELEASE decision only (reconcile deliberately still writes `Paid → spent` for these jobs), `plan_reconcile` documents its new parameter and drops a link to a fn that does not exist in this module, and a stale red-on-revert instruction now names the downgrade rather than the deleted filter. mobee-core 734 passed / 1 failed (pre-existing darwin /proc test, green on Linux CI); bins 35/35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: reconcile the attempt outbox's comments with #329's kind-3406 ACCEPT split Rebasing rounds 2-8 onto a dev that now publishes ACCEPT as kind 3406 leaves two comments describing the world before the split. `reduce_parsed_awards` no longer has to treat a 3405 multiplicity as the routine award+accept pair: the presence probe's filter now returns SELECTIONS only, so its agreement rule got strictly stronger without changing — two 3405s disagreeing on claim or seller are a genuine duplicate award (#322's harm), not a lifecycle artifact. The pinned-seller delivery guard inherits a fail-safe the split retired elsewhere: while ACCEPT shared 3405, an accept made the award-presence probe answer "present" for a job whose own award had aged off the relay. It no longer does — and a job that reached accept has a delivery by definition, so this guard is what now holds that population rather than the kind-sharing accident. No behaviour change; comments only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * review round 9: name what the control job actually proves, and stop the margin paying for a round-trip Both reviewers came back clean on production code — Fable NOTHING-SIGNIFICANT for the third round running, and the mutation pass killed all 11 mutants with zero survivors, independently confirming round 8's two claims: the mutant that survived rounds 6 AND 7 (under-lock arm deleted + slow prologue) now dies on Confirmed vs Pending, and the reconcile reinstatement dies on `kept: []`. The one finding was mine again, and the same class round 8 fixed: the control job's comment claimed it was "the only tooth for the sweep's carried-license wiring", but the sweep test pins that independently — passing None is killed there too. What the control UNIQUELY catches is loop continuation: turning the license bounce's `continue` into a `return` is noticed by nothing else in the suite, and a maintainer trusting the old comment could have deleted the "redundant" control and silently lost the only proof that one diverted job does not abort the whole pass. The comment and the assertion message now name that. Two NITs taken. The award is published BEFORE the pin, so the 2-second deadline margin covers only the 200ms lock handoff rather than a connect/send/disconnect round-trip as well — the margin had ~8x headroom on an in-process relay, but it was paying for work it did not need to. And `the_pinned_sellers_delivery_holds_the_refund` now asserts its premise (past the pay window) up front: every early return in `resolve_expired_attempt` leaves the same Pending/Reserved, so a probe flake would have passed it green having tested nothing. Re-verified after the reordering that the discriminator still bites: mutant (b) dies on Confirmed vs Pending, and both relay tests are stable across two runs. mobee-core 734 passed / 1 failed (pre-existing darwin /proc test, green on Linux CI); bins 35/35. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This was referenced Aug 3, 2026
Closed
orveth
pushed a commit
that referenced
this pull request
Aug 4, 2026
…1990) Per keeper:mobee ruling (b): the vendored relay now accepts the mobee-core kind set as a superset, alongside the fork's original DVM kinds. KEEP the fork's 4 DVM arms untouched (3400/5109/6109/7000). ADD, mirroring the existing 3400 const/arm style, all -> Scope::MessagesWrite, with the scope test extended to cover them: - trade block 3401 OFFER, 3402 CLAIM, 3403 RESULT, 3404 FEEDBACK, 3405 AWARD, 3406 ACCEPT - 30340 seller heartbeat, 31990 NIP-89 handler advertisement Ground-truth corrections to the ruling's enumeration (details in crates/buzz/README.md): - Added 3406 ACCEPT: mobee-core's block is 3400-3406, not 3400-3405 (#329 pay-bind). Omitting it would make the relay reject live ACCEPT events. - 31990 confirmed real via mobee-relay-write-policy DISCOVERY_KINDS (it is not in kinds.rs). kind-0 and 30617 mobee also emits are already scoped by the relay (KIND_PROFILE, KIND_GIT_REPO_ANNOUNCEMENT), so not re-added. Compiles with rustc 1.96; no unreachable-pattern warnings. 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.
Splits
ACCEPTonto its own kind,3406. Implements the ruled core of the v1 protocol spec (#323) — the accept-kind unit only.AWARD(3405) selects a claim before work.ACCEPTbinds payment to a verified result after delivery. While they shared one kind, the only way to tell them apart was to count a job's events — which is not a discriminator, because two events of one kind is also what a re-publish looks like. Two 3405s per job was the documented normal steady state.Emit — one line, and it was the dangerous one
Both call sites were the byte-identical string
let draft = award_draft(. The edit was targeted by surrounding context, and the enclosing function of each was then re-derived rather than trusting line numbers. Inverting them would move awards to 3406 and leave accepts on 3405 — a silent inversion of the whole change.No replacement double-award guard is owed, and here is the measurement
The concern going in was that the split would delete accidental double-award insurance. It does delete a redundant trigger, but the insurance is deliberate, documented, and untouched:
record_awarddedupes onaward_id TEXT PRIMARY KEY;job_idhas no unique constraint. So the accept-3405 inserted a second awards row, returnedAwarded::New, andon_awardspawnedexecute_joba second time.execute_job's own entry guard.should_resume_executionisstate.occupies_execution_slot()— admitsAwarded/Executing, excludesDelivered/Paid/Failed, asserted per-state with a red-on-revert bite atrun.rs:4405. The code names the case outright: "a REDUNDANT award (a second award event with a different award_id for a job already delivered/paid — seen live in the smoke) ... must NOT re-run the agent."Deliveredand the second spawn early-returns.⇒ The split removes the redundant trigger; the guard stays load-bearing for its independent restart-resume caller. Nothing to replace.
Second, quieter win. The extra awards row also made
job_award_time— which has noORDER BY— depend on which row SQLite returned first. That value is the delivery commit's authored-at, i.e. the thing that keeps a re-created delivery byte-identical after a restart. One row per job makes that deterministic rather than incidental.Seller side — KEEPER-RULED, REVERSIBLE
Not pre-ruled by gudnuf; decided by the keeper as the conservative branch, and one deletion to reverse.
The seller subscribes to 3406 on the existing AWARD REQ (
kinds([AWARD, ACCEPT]), not a new sub id — a second subscription is a second thing that can die quietly, and this one already has CLOSED handling and a stall watchdog). It dispatches toon_accept, which binds-if-unbound and never executes.Bind-if-unbound is a real precondition, not a formality:
record_awardis unconditional once entered and doesUPDATE claims SET state = 'awarded', which for an already-delivered job would regress a terminal claim row. Sojob_award_timeis read first and a write happens only onNone.The trade-off, stated rather than buried:
on_awardnow fires exactly once per job — the Duplicate awards are undetectable: kind-3405 carries both award and accept, and awards.job_id is a PK #268 conflation dies, and the seller can finally tell claim-won from pay-authorised.If you would rather the seller ignore 3406 entirely, deleting
on_acceptand the second kind from the filter is the whole revert.Readers — from measured site lists, not a symbol sweep
Both web registries, edited independently because they have drifted:
web/network/js/kinds.jsandweb/app/js/kinds.jsare separate full protocol registries, and the app copy carriesTRADE_STAGESthat network does not. Each needed the const,MOBEE_TAGGED_KINDS, andKIND_LABELS; app additionallyTRADE_STAGES. MissingMOBEE_TAGGED_KINDSmeans the REQ never asks for the kind, which renders as a silent "no activity" rather than an error.mcp.rsneeds no edit. The site list flaggedmcp.rs:251as a 3405 reader and it is one — but it is the AWARD tool's description, which the split leaves correct. A mechanical sweep over "3405 sites" would have corrupted it.docs/protocol.mdmoves with the wire it describesIts step 6 said the buyer "records the local pay-bind" with no published event. That was already false before this PR — accept published a 3405. So the doc describing shipped behaviour was carrying the #268 conflation, and this change is what makes shipped behaviour describable. Block widened to
3400–3406, kind-table row added, step 6 rewritten.Out of scope, untouched
The
#tvalue flip, thePROTOCOL_VERSIONbump, and the rename. Verified: zero changed lines touchMOBEE_TAG, the"mobee"literal, orPROTOCOL_VERSION(its one appearance in the diff is a hunk header, not an edit). NoMOBEE_HOME.Verification — numerators, not "green"
cargo check -p mobee-core --all-features --all-targets→ 0 error lines,Finished, cargo's own status captured without a pipe.cargo test -p mobee-core --all-features --lib→ 717 passed, 0 failed, 3 ignored, and the extended registry test confirmed present by name:kinds::tests::trade_path_kinds_are_the_contiguous_mobee_block ... ok.⚠ Default features are not a check of this crate.
lib.rsgatesseller_node,buyer, andjob_lifecyclebehindwallet, anddefault = []. A deliberate type error appended toseller_node/run.rspassed under default features and failed correctly under--all-features— that injected red-prove is the only reason the green above is readable, since nothing in cargo's output distinguishes compiled-and-fine from never-compiled.Remaining warnings are pre-existing on the base (#328's
AwardPresenceprivacy andaward_event_id); this PR'sjob_lifecycle.rsdiff is two hunks,@@ -24,2and@@ -1234, neither near them.🤖 Generated with Claude Code