Skip to content

Cut dev→main: the app ships as maxplayer (#262), wallet saga isolation (#269), release docs - #272

Merged
orveth merged 6 commits into
mainfrom
dev
Jul 30, 2026
Merged

Cut dev→main: the app ships as maxplayer (#262), wallet saga isolation (#269), release docs#272
orveth merged 6 commits into
mainfrom
dev

Conversation

@orveth

@orveth orveth commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

The v1 cut. Six commits from dev, headlined by the surface rename.

Merge with --merge, never squash

gh pr merge --merge. Squashing this collapses six reviewed commits into one and breaks main ⊆ dev
by content as well as ancestry — the next back-merge then re-proposes changes that are already
present. This is the process documented in #260.

What this does NOT do

Nothing publishes on this merge, and I re-proved it against dev's own release.yml rather than
inheriting the earlier proof.
Two independent legs:

  1. on: is push: tags: ['v*'] plus workflow_dispatchthere is no branch trigger, so a push
    to main does not start the release workflow at all.
  2. Both publishing jobs (Create the release, the npm publish) are gated
    if: github.event_name == 'push' && github.ref_type == 'tag'.

The gate is push and tag. Merging this is not a release.

Ordering after this lands

The v0.1.0 tag is not next and is gated on #271 — the acp cancel currently takes
Test mobee-core (money-path suite) down as skipped, so the money-path gate is absent repo-wide
rather than failing, and a skipped row looks exactly like a passing one from a distance. Tagging
before it lands would ship money code on a tree whose money-path suite never ran.

Sequence: this cut → dry-run #5 (rename only) → runner artifact + verify-runner-surface (kind-aware
asset names; the verifier must invoke the binary, never read --help) → dry-run #5b → #125 curl
installer against the runner artifact → #249 darwin-arm64 → v0.1.0 plain, no -rc
(release.yml treats any hyphen as a prerelease and would publish under --tag rc only, leaving
latest unresolvable for a first release) → clean-machine verify of both install paths → site
update last, so the site never advertises an install that 404s.

Back-merge owed

main carries exactly one commit dev lacks — c94ece6 (#270, mobile responsive). A back-merge
after this cut restores main ⊆ dev. Verified clean: a trial 3-way merge of dev into main reports
no conflicts, because #270 touches the header nav hunks and dev's index.html changes are the
mobeemaxplayerai URL and command renames elsewhere in the file.

Standing CI note

Build (acp) + money-path tests is red on dev and main both, independent of any PR: builds pass,
then Test mobee-core (acp) is cancelled at the ~2s mark and money-path is skipped. That is
#271, not this cut. Judge this by the other rows.

keeper:mobee-pr-feedback and others added 6 commits July 29, 2026 15:18
…landing page + restores main⊆dev)

RELEASE.md step 3. gudnuf-authorized (release-sequence).
…ring (#260)

`main` rejects direct pushes, so the dev -> main cut is a PR. Recorded with the
merge mode it requires: --squash gives `main` a commit that exists nowhere in
`dev` history and --rebase does the same for every commit it moves, so either one
breaks the ancestor relation the following step exists to maintain.

Also states that tagging still works as a direct push -- the ruleset targets
branches, not tags -- because a reader who has just been told `main` rejects
pushes has every reason to doubt the tag step.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…m dev (#236)

`workflow_dispatch` is only offered for a workflow present on the default
branch, and a `v*` tag runs the copy of the workflow in the tagged commit. A tag
cut from a `main` that predates `release.yml` therefore starts no run at all —
no output, no failure, nothing in the Actions tab.

That is the failure mode worth naming: an absent run is indistinguishable from
one nobody noticed. Both cases have the same fix, which the numbered steps
already prescribe; the trap is only reachable by tagging or dispatching without
merging `dev` into `main` first, most easily by cutting a release from a `main`
merged minutes before a workflow change landed on `dev`.

Records the one-line check that distinguishes "the trigger misfired" from "the
workflow was not in that tree".

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…#204)

* crossmint: a smoke script that runs, and states what it cannot prove

The smoke script's command surface had never been executed. Against the
real binary every invocation returns rc=1: `budget`, `config` and `job`
are not commands at all; `--home` is a post-subcommand flag on `wallet`
alone; `wallet balances` is `balance` and has no `--json`; `buyer restart`
does not exist; and the binary is not at ./target (CARGO_TARGET_DIR is
redirected). A script in that state does not fail at step 0 — it fails
partway, and on the real-money path that means after the melt.

The script now targets the surface the binary has, and is staged so the
external unknowns are measured before our own code is involved:

  --self-test  parsers against fixtures. The `status=needs_payment` branch
               only ever appears on a non-test mint, so fixtures are the
               only coverage available before real sats.
  --dry-run    testnut: gates, home discipline, env config, balance math,
               and that a melt which cannot route returns non-zero — the
               property every `|| die` guarding a real melt depends on.
  --stage-a    the hop by hand in three wallet commands. Answers what no
               hermetic test can: does LN route source -> target, and what
               does it cost. Ends in a fee-fit verdict against the cap.
  --stage-b    refuses, naming what it still needs, rather than pretending.

Safety is structural rather than procedural. Config is supplied entirely
by environment, and env overrides are never persisted: a home booted with
MOBEE_ALLOW_REAL_MINTS=true still reads `allow_real_mints = false` on
disk, so the real-mint fence is process-scoped and cannot be left open.
Bootstrap does write into MOBEE_HOME, and an unset MOBEE_HOME resolves to
~/.mobee, so every invocation is funnelled through one wrapper that
refuses any directory this run did not itself create and mark.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* crossmint: fund the probe wallet, and shape-check what gets melted

Stage A created a fresh empty home and then asserted a source balance, so
the first real run would have died at "buyer holds 0 sats" — the funding
step was missing entirely. Paying the invoice happens out of band and by a
human, so funding is two invocations (`--fund` prints the bolt11,
`--fund-complete` issues once it is paid) sharing one throwaway wallet.
That means a home has to be reusable across invocations: `ensure_home`
reuses one carrying our marker and still refuses any directory this
tooling did not create.

The authorization token now names the TOTAL FUNDED EXPOSURE rather than
each step's amount — one human decision about how many real sats are at
risk, which is the shape the spend was approved in.

A mint that auto-funds prints `minted_sats=...` where a real mint prints a
bolt11. Both are non-empty, so an emptiness check would feed that line
straight into `wallet melt`; `is_bolt11` checks the shape instead, and the
self-test pins both directions.

Verified: self-test 11/11, dry-run rc=0, and --fund / --fund-complete /
--stage-a / --stage-b each rc=1 without matching auth, creating no home
before the gate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* crossmint: stop truncating quote ids at the underscore

Live Stage A hit this twice. `parse_quote_id` matched [A-Za-z0-9-], which
has no underscore, so a real quote id truncated at the first one:
lcLc0JwHHCIG_UIwQ8cvPB-LXgiVfSxE6ZO6Tq1b became lcLc0JwHHCIG. The result
is still shaped like an id — it is a PREFIX of the real one — so nothing
failed at the parse. It failed two steps later, at mint-complete, with
"no stored amount". Both times the full id recovered it; no sats lost.

Match to whitespace instead. Quote ids are opaque: testnut issues UUIDs,
the real mints issue base64url. Never enumerate the characters of an
identifier you do not define.

The self-test is why this shipped. Its fixture was a UUID — copied from
testnut, the mint the dry run CAN reach — so a fixture standing in for the
unreachable real format inherited the format of the reachable one, and
proved the parser against a character set that could not fail. Both shapes
are pinned now, the base64url one verbatim from the live run, and the old
class is confirmed red against it (12-char prefix of a 40-char id).

`--amount` is now passed on both mint-complete calls: a quote raised by a
different process leaves no stored amount in this home, which is the error
the live recovery actually hit. The strand message prints the exact
recovery command with the full id and the env fence, and says to copy the
id from that line rather than from earlier output.

Verified: self-test 13/13, dry-run rc=0, refusal gates rc=1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* crossmint: name the real cause of "no stored amount"

The previous commit explained the live failure with a mechanism I made up:
that a quote raised by a different process leaves no stored amount in this
home. The run data refutes it — leg-1 recovery used the full id with NO
--amount and succeeded, so the quote was stored all along under its full
id.

The source agrees. `complete_mint_by_id` only reaches "has no stored
amount; pass --amount" when the quote lookup returns None. The message
means THE ID WAS NOT FOUND; the code then guesses you need --amount,
having no way to know the id is a truncated prefix. An error's suggested
remedy is a hypothesis, not a diagnosis — and I took the remedy as the
cause, which is inference wearing the clothes of a record.

The character class is the whole cure. --amount stays as a cross-check,
which is what it actually is: when the quote IS found, mint-complete
refuses a completion whose amount differs from the stored one, so passing
it pins that we are completing the quote we raised. Comments at both call
sites now say that rather than claiming to fix anything.

Verified: self-test 13/13, dry-run rc=0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: worker:mobee-crossmint <orveth@agi.cash>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* rename: the application ships as `maxplayer`, buyers are racers

The binary, the npm packages, the release assets and the CLI's own output all
carry the product name; the crates, the state directory and the internal
identifiers do not.

`[[bin]] name = "maxplayer"` is what separates the two. Without it Cargo derives
the executable name from the package, which is why the application rename looked
like it forced a crate rename. It does not, and the crates stay `mobee`
deliberately: nothing publishes to crates.io, so those names are invisible to
users while renaming them would touch most of the workspace.

npm goes to the ruled layout -- the launcher unscoped as `maxplayer`, payloads as
`@maxplayerai/linux-*`. Directories under npm/ keep their paths: seven sites
hardcode `npm/mobee`, and nothing derives from the directory name, so moving it
would be churn with a failure mode.

package-release-asset.sh takes the artifact name as an argument rather than
hardcoding the new one. The release matrix is about to ship a second binary from
the same script, and hardcoding here would mean un-hardcoding it there.

Two renames are deliberately NOT made, both because they would change behaviour
rather than naming:

  * the `buyer` and `sell` subcommands keep their tokens. `sell` cannot become
    `run` because `run` already exists as a different command, and `buyer` pairs
    with a self-spawn argument in daemon.rs by string -- a mismatch compiles
    cleanly and fails at runtime, in the money-path daemon.
  * MOBEE_HOME and ~/.mobee are untouched. That directory holds the key, the
    wallet and the spend ledger; pointing it somewhere new is a migration with
    its own proof obligations, not a rename.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* rename: follow the binary rename into the nix and artifact-verifier paths

The `[[bin]]` change renames the built executable, so every path that names it
had to follow. Six were missed, and `nix run` was the shipped regression: the
flake's app pointed at `/bin/mobee`, which no longer exists.

  flake.nix                     meta.mainProgram, apps.default.program
  scripts/verify-static-artifact.sh   default binary path, staged name, container invocation
  scripts/verify-arm64-artifact.sh    the same three, plus the `^mobee <semver>` assertion
  scripts/verify-release-version.sh   usage example
  docs/PACKAGING-SCOPE.md             invocation example
  docs/SELLER-QUICKSTART.md           MOBEE_BIN path
  .gitignore                          comment naming the default

`-p mobee` in cargoBuildFlags stays: that selects the package, not the binary.

The two artifact verifiers had a second coupling behind the first. Each stages
the binary under a fixed name and then invokes that name inside a container, so
renaming the staged file without the invocation would have failed at runtime
rather than at build. The arm64 verifier also greps the version output for
`^mobee`, which the renamed binary no longer prints.

Evaluated rather than assumed: `nix eval` on apps.default.program now returns
`.../bin/maxplayer`, and meta.mainProgram returns `maxplayer`. The store path
stays `mobee-0.1.0` because that is the package name, which is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* rename: the last `npx mobee` — a track heading naming an install path that will not exist

Track B in the packaging scope is the npm install path, and its heading still named the command
`npx mobee`. That package was never published, and after this PR the launcher is `maxplayer`, so the
heading named an install path that cannot work in either direction.

Found by re-running the acceptance predicate after the rebase rather than by re-reading it: the
`npx mobee` clause came back 1, not 0, against a PR body asserting the docs no longer advertise it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…efusals (#269)

A Send(TokenCreated) saga with no confirmed outgoing tx wedges the wallet
permanently, and the refusal is wallet-wide rather than job-scoped: one stuck
saga blocks every subsequent outbound payment. The Swap family already resolved
this shape on mint truth; the Send family had no equivalent.

resolve_one_send_saga resolves exactly one case, the send that demonstrably
completed: reserved inputs all SPENT at the mint means the send executed, so
record the inputs Spent and drop the saga. Everything else refuses fail-closed.

all-UNSPENT refuses here where the Swap path rolls back, because a Send has a
counterparty. All-unspent proves only that the token is unclaimed, which is
indistinguishable from delivered-and-awaiting-redemption, and the token is
bearer so it names no payee. Rolling back would queue a payee's money for
re-spend. Same mint answer, weaker strength; classify_reserved_inputs is shared,
the interpretation is per-family.

The Send completion path deliberately does not call Wallet::restore. A Send's
outputs are the token, and restore re-derives from this wallet's own seed, so
restoring would pull an outstanding token's proofs back into our balance and
silently reclaim money already handed over.

Both resolver call sites propagated refusals with `?`, so a single unresolvable
saga aborted the whole pass and left resolvable ones stuck, with progress
depending on saga iteration order. Refusals are now recorded per-saga in
RetireReport.unresolved and the pass continues. Applied at both call sites, since
the Swap one carries the same defect. Fail-closed is unchanged and asserted:
recover_unmapped_sagas still refuses over survivors, so the pay path stays
blocked.

Clearing an already-stranded row is out of scope. Deleting a proof-less saga
touches no proof, so it is safe with respect to money and not with respect to
the record: the row is the only store of SendOperationData.token, so an
automatic drop would destroy a possibly-claimable bearer token with no chance to
capture it first.

The NUT-07 test fake returned every configured state regardless of which ys were
requested. Since classify_reserved_inputs requires the response Y-set to equal
the requested set, that refuses any wallet holding more than one saga, so a
per-saga isolation test would have reported green with neither saga resolving.
It now filters by requested ys.

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 29, 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 Jul 29, 2026 11:58pm

Request Review

@orveth
orveth merged commit 433fcea into main Jul 30, 2026
4 of 5 checks passed
@orveth orveth added this to the v1 milestone Jul 30, 2026
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