Skip to content

feat(protocol): split ACCEPT onto kind 3406, so a pay-bind is not an award - #329

Merged
orveth merged 2 commits into
devfrom
feat/accept-kind-3406
Aug 1, 2026
Merged

feat(protocol): split ACCEPT onto kind 3406, so a pay-bind is not an award#329
orveth merged 2 commits into
devfrom
feat/accept-kind-3406

Conversation

@orveth

@orveth orveth commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Splits ACCEPT onto 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. ACCEPT binds 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

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(. 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_award dedupes on award_id TEXT PRIMARY KEY; 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 entry guard. should_resume_execution is state.occupies_execution_slot() — admits Awarded/Executing, excludes Delivered/Paid/Failed, asserted per-state with a red-on-revert bite at run.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."
  • An accept arrives after a delivery the buyer has verified, so the job is Delivered and 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 no ORDER 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 to on_accept, which binds-if-unbound and never executes.

Bind-if-unbound is a real precondition, not a formality: 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 trade-off, stated rather than buried:

If you would rather the seller ignore 3406 entirely, deleting on_accept and 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.js and web/app/js/kinds.js are separate full protocol registries, and the app copy carries TRADE_STAGES that network does not. Each needed the const, MOBEE_TAGGED_KINDS, and KIND_LABELS; app additionally TRADE_STAGES. Missing MOBEE_TAGGED_KINDS means the REQ never asks for the kind, which renders as a silent "no activity" rather than an error.

mcp.rs needs no edit. The site list flagged mcp.rs:251 as 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.md moves with the wire it describes

Its 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 34003406, kind-table row added, step 6 rewritten.

Out of scope, untouched

The #t value flip, the PROTOCOL_VERSION bump, and the rename. Verified: zero changed lines touch MOBEE_TAG, the "mobee" literal, or PROTOCOL_VERSION (its one appearance in the diff is a hunk header, not an edit). No MOBEE_HOME.

Verification — numerators, not "green"

  • cargo check -p mobee-core --all-features --all-targets0 error lines, Finished, cargo's own status captured without a pipe.
  • cargo test -p mobee-core --all-features --lib717 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.rs gates seller_node, buyer, and job_lifecycle behind wallet, and default = []. A deliberate type error appended to seller_node/run.rs passed 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 AwardPresence privacy and award_event_id); this PR's job_lifecycle.rs diff is two hunks, @@ -24,2 and @@ -1234, neither near them.

🤖 Generated with Claude Code

…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>
@vercel

vercel Bot commented Aug 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
mobee Ready Ready Preview Aug 1, 2026 1:33am

Request Review

… 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>
@orveth
orveth merged commit 17e850c into dev Aug 1, 2026
6 checks passed
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>
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant