Agent-First Pay v0.10.0: An Outage Is Not Consent
When every provider's quote failed, the send confirmation printed 'skipping confirmation' and returned true — the payment went out having asked nobody. That, a rounding bias that made split payments free against the global cap, and an allowlist that let one configured category start a wide-open public daemon. Plus gRPC and its hand-rolled crypto layer are gone, replaced by a resource API with an OpenAPI face.
Three of the things in this release are the same kind of mistake: a safety mechanism that reported success while doing nothing. They are worth reading together, because none of them looks wrong at the call site.
An outage is not consent
Before a send, afpay asks each provider for a quote so the confirmation can name
the fee. When every provider’s send_quote errored, the code printed skipping
confirmation and returned true.
Returning true there means confirmed. The payment went out having asked nobody
— not because anyone decided the confirmation was unnecessary, but because the
thing that produces the fee number was down. The cashu branch immediately beside
it had always done the opposite.
It asks anyway now, and says the fee is unknown: the amount named where the amount is knowable, the destination named where it is not. A confirmation whose whole job is to put a person in front of a payment cannot treat its own failure as an answer from that person.
A payment worth something cost the limit nothing
amount_to_usd_cents rounded each reservation to the nearest cent.
Take a USD-priced token with six decimals, and send 4,999 base units. That is 0.4999 cents, which rounds to zero. The reservation charged zero against the global USD cap — so the same send, repeated any number of times, never moved the limit at all. Not “eroded it slowly”: never touched it. The split-payment bypass was free.
It rounds up now. This number is charged against a limit, and rounding a payment down is the ledger saying it cost less than it did. The bias only ever runs against the spender, and splitting is now strictly more expensive than not splitting rather than free.
Worth being precise about what this does not do: it does not make this a
fixed-point ledger. f64 still decides the value. What it removes is the
direction of error that a limit can be walked past.
Silence stopped counting as an answer
--public-listen refuses to start a public daemon unless the allowlists say
what is permitted. It accepted any one non-empty allowlist as satisfying that.
So configuring allowed_mint_urls alone — a perfectly ordinary thing to do —
started a public daemon whose Solana, EVM, Bitcoin and Lightning endpoints were
all still unrestricted, under a banner that said fail-closed. The helper behind
it, any_set(), asked “is any category configured”, which is the question that
allowed this.
Every category must be decided now: listed, or ["*"] to say out loud that it is
open. That is the same permission as before — you can still open everything — but
it has to be written down, so an unanswered category is a refusal rather than a
default.
One machine face, and it is a resource model
POST /v1/afpay took an opaque Input union: the CLI tunnelled over HTTP, which
the normative baseline names and bans in §5. It had no in-repo consumer, and it
sat beside gRPC, so afpay had two machine faces for one domain and one of them
was a shape the baseline forbids.
There is one now, and it is a resource model: a discovery face, AFDATA envelopes,
redaction at the boundary, and idempotency where money moves. Everything
Input::is_local_only marks has no route at all, so a leaked bearer token cannot
raise its own spending limit — a property that now holds in three places, and the
federation client mirrors it before any bytes leave the process. An OpenAPI
document and a JSON schema per request and result ship with it.
gRPC could not simply be deleted, because it was never a second API — it was the
federation transport. RemoteProvider became an HTTP client against those same
routes, which let the encrypted RPC mode go. With it went protoc as a
build-time toolchain, eight direct dependencies, and a hand-rolled AES-GCM layer
keyed by HKDF from a static secret: no forward secrecy, no identity beyond the
shared key, unreviewed, and precisely what baseline §7 means by don’t rebuild
transport. Confidentiality belongs to WireGuard, an SSH tunnel or a TLS proxy,
and the README now says how to set one up instead of saying “put it behind
TLS/firewall” and leaving it there.
How each panel reaches a person
The three panels were window-only: they built a session and called window(...)
directly, so a machine with no display could not open one at all and
AFUI_DELIVERY meant nothing here.
They go through a delivery plan now — but not all three through the same one, because these panels are not the same kind of thing.
The two watch panels offer all three deliveries, link included. What leaks if
that URL does is a view of balances or a receive code, bounded by the attention
policy, and pointing a phone at a receive code is what the panel is for.
afpay ui send does not offer link. That URL is a bearer capability, and what
it would bear is the authority to move money — whoever holds it approves the
payment. Answering from another device is still available through session plus
afui session serve, which is the front door with a credential on it.
And the link URL is now actually revealed. The panel shipped publishing
link_url_secret: "***" beside a message saying the page was reachable at it —
AFDATA redacts _secret fields, correctly, everywhere except the one event whose
whole job is handing that URL to a person. It is revealed by name at that one
call site, with the rest of the event still redacted. A test opens a link panel
and asserts the URL is an http:// a person could paste; nothing asserted that
before, in any spore, which is why the gate was green while the feature was
unusable.
The gate that could not run
Two frontend fixes came out of the same root cause, and it is a good argument for treating a warning as an error only if you then keep the build clean.
TuiApp.frontend was never read. Under -D warnings that was the whole spore’s
static gate — red on main, which meant nothing else here had been run in full for
a while. The field is gone rather than read: the TUI entry point is picked by
that value, so inside the TUI it is a constant, and branching on it would be
branching on one.
With the gate running again, six failing tests appeared. Overriding a panel had
been broken since the page moved onto the shared page builder: that change
registered the shared templates under their file paths and rewrote afpay’s own
pages to match, so the built-ins kept working and nothing said otherwise — while
a person’s own page, extending layout.html.j2 the way the docs and every
example show, stopped resolving. Templates are registered under the logical name
an override author writes again, with the file path kept separately.
Breaking changes
POST /v1/afpayis gone, replaced by the resource routes. The OpenAPI document is the reference.- gRPC and the encrypted RPC mode are gone. Federation speaks HTTP to the same routes; put confidentiality in WireGuard, an SSH tunnel or a TLS proxy.
--public-listenrequires every allowlist category to be decided. Use["*"]to declare one open.- Reservations round up. A payment that used to cost the global cap nothing now costs it a cent.
afpay ui senddoes not offer--mode link. Usesessionwithafui session serve.- The skill now says a limit holds the planned amount plus an estimated fee rather than the final charge, because that is what the code does.
Getting it
$ brew install agentfirstkit/tap/afpay
$ cargo install agent-first-pay