Added
-
Refunds are recorded on the international rail.
order.refundedpopulatespayments.refunded_minor, closing half the net-revenue overstatementdocs/ADMIN_WIRING.mdhas recorded since the ledger shipped. The Paystack half is still open, and the doc now says which half is which. The refund deliberately does not touch the entitlement — whether access ends arrives separately assubscription.revoked. -
The desktop app updates itself, and says so exactly once. 0.1.1 shipped the Rust half — the updater plugin, a real signing key,
createUpdaterArtifacts, CI publishinglatest.json. Nothing ever called it. The app-side half is here: a bridge inlib/desktop.ts, the policy inlib/desktop-update.ts, and one pill in the title bar.- Silent until the restart. First check 20s after launch — launch is the busiest moment the app has and an update check is the least urgent thing happening — then every 6h, downloading in the background with no announcement. The user is interrupted once, at the only moment that costs them anything: their place, for the few seconds of a restart.
- A bar pill, not a toast. An update that is already downloaded is waiting for the
user, not announcing itself, and a toast that auto-dismisses turns that choice into a
race. The pill uses
cs-bar-pilland the--bar-*tokens, so it de-chromes into the desktop caption for free with noisDesktop()branch in the component. It sits next to the window controls, because restarting is a window action. - A failed check is never surfaced. Offline, proxied, and releases-host-down are
indistinguishable from inside the app and none of them is something the user can act on
mid-sentence, so
checkForUpdateresolvesnullfor all of them — the same answer as "you are current". The timer heals it. process:allow-restartis a second, non-obvious permission.updater:defaultcovers check and install; the restart is a different plugin. Grant only the first and the update installs perfectly and the app never comes back — the same silent-denial shape as thecore:event:listenbug, now pinned intests/desktop-capabilities.test.tsalong with the signature config itself.- Three guards, each a real bug, which is what
tests/desktop-update.test.tsis for: never re-download something already staged (the timer keeps firing for days, so the unguarded version re-fetches ~90MB every 6h forever), never run two checks at once (two concurrent installs corrupt the artifact), and never land inreadyafter a failed download — offering Restart now into a half-written file is worse than no updater. .cs-bar-labelis now the shared "drop the word at ≤760px" class, rather than the update pill borrowing.cs-agent-label— a class that means "the Agent toggle's label".
-
The desktop harness can search the web.
create_web_linkswas hardcoded off for the Claude Code engine on the grounds that "on this path there may be no server at all". That is true of the fully-local mode indocs/DISTRIBUTION.md— whichsrc-tauri/src/modes.rsstates, in as many words, is not implemented. Both modes that ship (cloud and self-host) load the window from a real server, so the constant was refusing a capability every actual install has.- The sandbox was never what blocked it.
create_web_linksis a deferred op: the CLI emits the intent and the webview resolves it throughCanvas.applyAIActions→/api/search. The four harness flags —--tools ""included — are untouched, and the CLI still reaches the network for exactly nothing. - It is asked, not assumed.
probeWebSearch()calls/api/searchwith an EMPTY query: the route's existing early return, which runs the rate-limit, sign-in and key checks and then answers{urls: []}without calling the provider. 200 means the verb will really work, 401 means signed out, 501 means the deployment has no key — and finding out costs no search quota. A "yes" is kept for the session; a "no" is re-asked after a minute, so signing in mid-session doesn't need an app restart. The probe fires alongside listener setup so it adds no latency to the turn, and it never throws: a failed check is a "no", never a dead turn. buildHarnessSystemPrompttakes the capability as an argument rather than discovering it, so it stays synchronous and testable. The empty-query probe contract is now written down inapp/api/search/route.ts, because tightening it into a 400 would silently turn web search off on every desktop install.
- The sandbox was never what blocked it.
Fixed
docs/DISTRIBUTION.md§1.6 no longer claims the updater keypair doesn't exist. It was generated for 0.1.1 — public half intauri.conf.json, private half in Actions secrets — but the note saying otherwise survived, alongside instructions to generate one. Following them would have produced a second keypair, which does not merely fail to update: every installer already in the wild carries the first public key compiled in and actively rejects anything signed by another. The section now records what is genuinely still unverified — that no update has ever actually been installed by an existing install, which needs two real releases and a copy of the older one to prove.
Changed
- The international rail is Polar, not Paddle. Same job — merchant of record, USD,
everyone outside Paystack's six African markets — and the swap needed no migration: the
billing tables were already
provider+ opaqueprovider_*text, which is the second time that decision has paid for itself.- It is not a fee win, and
docs/PRICING.md§4b says so. Polar's Starter tier is 5% + $0.40 with +1.5% on international cards, and on this rail essentially every card is international by definition — budget ~6.5% + $0.40 against Paddle's flat 5% + $0.50. Roughly a wash. The reason to move is the shape of the integration, not the rate. - Three things deleted code. Polar hosts its own checkout, so
app/checkout/page.tsx,components/PaddleCheckout.tsxand the public client token are gone — a redirect is now just a redirect, exactly like the Paystack path.external_customer_idis our Supabase user id, so the create-customer-then-recover-on-409 dance is gone and the portal can be minted for someone whose webhook hasn't landed — the one failure that used to look exactly like being cheated. And money arrives as integers, so the string parser is gone. - The amount check compares
subtotal_amount, not the total. Polar has three tax modes and defaults to location-based: US/CA/IN pay tax on top of the list price, everyone else has it carved out of the list price.subtotal_amountis the only field that equals the catalog under both, so guarding on the total would refuse correct payments from one half of the world. On an inclusive charge a $14 plan settles $14.00 gross → $2.33 tax → $11.67 net —docs/PRICING.md§4b.4 works through what that does to margin, and therevenue_by_monthgap it opens. - The webhook key is the raw secret, not a base64-decoded one. Polar follows Standard
Webhooks, but its SDK base64-encodes the secret before handing it to the spec library,
which decodes it again — the two cancel out. Reading the spec alone leads you to decode a
secret that was never encoded, producing a well-formed signature that matches nothing and
fails identically to a wrong key. Pinned by its own case in
tests/billing-polar.test.ts. POLAR_ENVhas no fallback sniff. Sandbox and production tokens share thepolar_oat_prefix, so unlike Paddle'ssdbxthere is nothing honest to guess from. Unset means production; a wrong guess 401s rather than charging, so both halves fail closed.customer.state_changedis deliberately not subscribed to. It reads like a free reconciler, but it fires on the same changesorder.paidand thesubscription.*events already cover — a third writer of onesubscriptionsrow is three ways to disagree.- The return trip is
?checkout_id=(Polar's{CHECKOUT_ID}placeholder) rather than?_ptxn=, and/api/billing/verifyresolves it through the order rather than granting off the checkout, so the ledger keeps one row per payment instead of two.
- It is not a fee win, and
- MCP still has no web search, and now says why.
server-ops.tsfiledcreate_web_linksunder "needs a Storage bucket, a provider key, or image decoding" — none of which is true of it:searchWeb()is server-side already andrunDeferredmints link cards a few lines below. The real reason is about who calls that path. External assistants over MCP have their own web search, so the right shape is for them to search with their own tools and sendcreate_linkwith the real URL — the choice of page then sits with the agent the user is actually talking to, instead of spending the operator's search quota inside an unattended loop. Recorded in the comment so nobody wires it in by mistake. - Web search moved from Brave to Serper. Brave retired its free Search API tier in
February 2026 — every plan now wants a card on file and bills $5 per 1,000 queries. Serper
gives 2,500 queries on signup with no card and charges $1 per 1,000 after, so the same
feature got five times cheaper and stopped needing a payment method to try.
- The swap is confined to
lib/search.ts./api/search, thecreate_web_linksop, the canvas resolver, the SSRF guard and the prompt gating are all untouched, because none of them ever knew who the provider was — the module's whole contract is "a phrase in, public URLs out". A provider change that costs one file is what that contract was for. - The result shape barely matters, and that is the point. Serper returns a whole SERP —
answer boxes, knowledge graph, "people also ask", related searches. We read
organic[].linkand drop every other block on the floor. Search results are attacker-authored prose, and this module's job is to not carry prose; a test now asserts that a URL appearing only in an answer box never reaches the board. - Over-fetch is capped at 10, which is a billing decision. Serper charges one credit for
up to 10 results and two for 11–100, so the old
want * 2(up to 20) would have doubled the price of every large search to buy one spare candidate. BRAVE_SEARCH_API_KEY→SERPER_API_KEY. The stale "2,000 free queries a month" promise is corrected in.env.example,docs/AI_SURFACE.md,docs/USE_CASES.mdanddocs/ADMIN_WIRING.md.
- The swap is confined to