Build log
What was built, in the order it was built.
Every line below is a commit, in the words it was written with. The ones that say what was found and fixed are the ones worth reading: a product is the sum of what it refused to leave wrong.
218 entries. Newest first.
2026-09-05
- Touch: the payee host's navigation and the environment switch sized for a thumb; the mobile pass recordedee156d4bd5
- Touch: every control at least 40px tall and every text control 16px under a coarse pointer
Measured at 375 pixels on both production hosts: no page scrolled sideways, but small buttons were 26px, inputs 34px, and inputs read at 13.5px, which makes iOS zoom on focus. Nothing changes on a desktop.
10899f04dd - Demo data in the currency it was paid in; the welcome amount from one file; the guard learns two sums
Twenty fixture payments read as a currency no pool pays in. They now read as the token they were paid in, and the overview's due lines and the close's accrual read one currency each. The welcome payment's amount is one shared file for the minting script, the payee pages and the invented-figures guard, which also learned the ledger's monthly sums and the close's accrual to a cut-off, and reads clean across every screen.
a290a7a5b6 - One sentence above each control, the rest one click away
A Why disclosure, remembered per browser and expanded in print, now holds the second and later sentences of the opening paragraphs on nine screens. Nothing was deleted; the person who wants the reasoning sees it every time, and the person who does not is not asked to read it again.
beae1b8dad - The payee host lands where a pool exists; payable payees first; days not hours; the privacy figure explained
A payee with no link and no cookie used to land on Live and be told there was nothing to read. Now the host picks the mode that has a pool, the footer names the test network when live has none, and the three no-pool screens say what to do and that the words are unaffected. Send lists payable payees under their own heading. Waits on the approvals desk read in days past two. The inline privacy figure carries its sentence.
dd31955510 - The sidebar in four groups: Pay, Books, Controls, and Agents only when there are any
Eighteen flat entries made a payroll tool look like a control room. Pay stays open; Books and Controls fold and remember; Agents is hidden until the organisation registers an agent or opens Developer. A group opens by itself when the current page is in it. On a phone the groups become one strip with nothing folded. A test walks every payer page and fails if one is unreachable from the sidebar or listed without a page.
ce41165f09 - Payee invitations: one link, the payee makes their words or gives a wallet, the address lands in the directory
From Payees, one press makes a fourteen-day, single-use link. The payee opens it on any device, chooses in their own words between making twelve words there, giving a wallet they already have, or pasting an address they already hold, and their address is written into the payer's directory against the invitation and screened, without anybody typing it. Every refusal is a sentence for a person with no account: a link never sent, answered, cancelled or expired; a bad address or wallet; an address somebody in the directory already has; a name that already has an address on file. A hand-added row of the same name is completed rather than twinned. Sending again voids the last link. The token carries its environment so a test invitation shows the test banner to somebody who has never seen the site.
4fe85df1f5 - The first run: five steps on one card, the treasury words made and split there, no mandate in sight
A new organisation's overview opens on one card: the organisation, the treasury words, the funding, the payees, the first run. The words are generated in the browser, shown once behind a written-down checkbox, recorded as an address only, and the recovery split is offered on the same card with the words already in it. A colleague on another device is told the words exist and where; words that would open a different treasury are refused at the split, named on unlock, and refused at deposit. The funding step ticks from the wallet's own confirmation. The authority payroll spends under is created with the organisation and healed at run time, so nobody is asked to sign a mandate. Organisations carry a kind, so metrics and invoices count customers and the test suites can leave rows the append-only audit log will not let them delete.
4f31490407 - The portal owns the numbers and the keys; invoices paid in stablecoins from the pool
Every price the product charges lives in one row set only in /admin: basis points on settled volume, a cap per payment, the platform fee, a floor, pilot months, managed screening fees. Every key Recurro itself holds lives in an encrypted settings store set in the same portal and read before the environment: vendor keys for Managed, Stripe's keys for the queued card and bank path, and Recurro's own receiving address, viewing key and wallet. The address and viewing key are derived in the browser from the treasury passphrase; the spend key never leaves it.
8203597a19 - Screening tiers: bring your own keys, or pay Recurro to run it
Three tiers per organisation, set in the admin portal. Lists: the public lists and the oracle, included. Own keys: the organisation pastes its vendor keys on the screening page, held AES-256-GCM under a server key, only the last four characters ever shown again, tested against the vendor the moment they are saved. Managed: Recurro's subscriptions answer, every vendor call metered with its outcome so a failed call is never charged, the price a number an administrator typed and 'to be agreed' until then.
9e4b351baa - Screening page: five minutes for the fetch and the sweep on Vercelbe2d928cf4
- Production probe: the admin page answers 404 to strangers on purpose7e1471f19a
- Every screen reachable on its host, checked against the deployed hosts after every deploy
/confirmations and /screening answered nothing at this address on app.recurro.xyz, and /api/screen was unreachable from pay.recurro.xyz, because each deployed host serves only an allowlist and none of the three had been added to it. Locally one host serves everything, so every browser check passed. The allowlist is fixed, the middleware and the allowlist share one list of what the payee host serves directly, a test walks every page in src/app and asserts its host serves it, and scripts/check-production.mjs probes the deployed hosts for every page and API after a deploy.
a39e2d9a68 - The public pages say what shipped: two stablecoins on two chains, wallets, payee recovery, screening
The landing page gains a section for who gets paid and what is checked first. The roadmap marks straight-to-wallet settlement, payee recovery and screening as shipped, the live pools as the next step with the deploy script ready, and false-positive review as building.
eb06f236c7 - Screening: the public lists, every vendor a key away, and a verdict on every payee
Seven public sanctions sources (OFAC SDN names and aliases, OFAC consolidated, OFAC digital-currency addresses read from the SDN XML, the daily GitHub extraction of the OFAC ETH addresses, UN, EU, UK OFSI) fetched into our own tables, each parser pinned against a fragment of that day's real file. The Chainalysis sanctions oracle on Ethereum read for every address. Keyed adapters for the Chainalysis API, TRM, OpenSanctions, Companies House, OpenCorporates, Sumsub and Persona behind one provider interface, each on the moment its key exists and listed on /screening as off, with the variable, until then.
21730dca02
2026-09-03
- Payees who cannot lose their pay: straight to a wallet, and recovery for the words
Two kinds of contractor exist. One keeps twelve words and wants the privacy; one will not, and wants the money in the wallet they already have. Both are served, and both are said plainly.
5cad0bb2fc - Many pools: USDC and USDT on Base and Ethereum, a line's own currency, a cap on every pool
The founder's correction: a customer paying contractors has no use for a test network and no use for one coin on one chain. So the registry is a list, one pool per token per chain per environment, keyed by the instrument ids the database already uses. A pool holds one token because the circuit binds the asset, so privacy is per pool and every screen says which. On a mainnet the token must be the issuer's own address, read from each chain today.
5770db53e6 - One pool per environment, named in the path
Until now one pool, read from one set of variables, and its chain written into the pool route and the agent rail as a constant. Correct for exactly as long as the only pool was the test one.
0c5e863230 - QuickBooks Online journal, checked against Intuit's own import page and schema
Struck yesterday as a phantom because no exporter existed. Wrong call: the receipt mockup promised it and the ledger notes asked for it, so it is built to the standard the Xero format got, with the import format in hand first.
0ad5c168b7 - Log check: one commit of slack, which regeneration cannot avoid
The commit that adds a regenerated log.json is always one newer than the history the file describes, so a check demanding HEAD exactly fails on the very commit that fixed it, and did: the gate went red at its first step, the && chain stopped before lint and tests, and a grep swallowed the exit code so a commit landed on a red gate. The check now allows one commit behind and refuses two. The gate is run reading its real exit status.
08e6ebade1 - The build log, from the commits, on the marketing site
recurro.xyz/log: every commit subject and the first paragraph of its message, grouped by day, newest first. The commits are written for readers, which is why the page can exist without anybody writing anything twice.
28cdc6f031 - Protocol package: ready for the day the org exists
Everything a publish needs that does not need the organisation. A CHANGELOG for 0.1.0, which the README already told readers to consult and which did not exist, now shipped in the tarball. The README's module table gains attest, added this week and missing from the list a consumer reads first. And a release workflow, dormant until the org exists: on a protocol-v* tag it checks the tag names the version in package.json, runs the tests, and publishes with provenance from the job's OIDC token, which is why provenance is not in publishConfig. The header says plainly what has to be true before its first run.
1204f612be - The invented-figures guard looks at every screen, and found a summed currency
The guard walked seventeen routes written when the product had that many, named two mock ids from before the real tables existed, and never gained the ledger, the tax year, confirmations or payees, the screens most likely to carry a hand-written figure. Thirty routes now, dynamic ids resolved from the database and skipped out loud when none exists, and five figure sources the newer screens are entitled to.
1246b28896 - Lint is a gate, and the desk rows it led to
npx eslint src is in npm run check, gating on errors. Zero remain. The last three were setState-inside-an-effect, and each is fixed by putting the state where it belonged rather than by silencing the rule. The SLA clock defers its first tick by one macrotask, keeping the hydration-safe null start; proven on the desk with twelve overdue clocks, none in the server render, no hydration message. The two proving clocks move into the block that exists only while proving, through one useElapsed hook, so they start at zero by construction. TryFlow was driven end to end with a real voucher: proved in 1.6 seconds, settled by the relayer, a real transaction hash. Too fast for any read to catch the tick; what the run proves is that the refactored screen reaches done with no error. MoveOut is the identical structure and cannot be driven without funded notes, so it stands on typecheck, lint and sameness of shape, said plainly.
ac8124feb2 - SLA clock: same hydration-safe pattern, first tick deferred
now starts null so the server and the first client paint agree, and the effect fills it in. The first fill was a synchronous setState inside the effect, which is one of the four lint errors keeping eslint from being a gate. Deferred by one macrotask: identical behaviour one frame later, and a clean file.
55b69bb88d - Bundle download: a filename a header can carry
The evidence bundle downloaded as recurro-evidence-MND-0043 · Aug run.json. A plain filename= parameter carries no encoding, so a middle dot in it is invalid under RFC 6266, and some browsers mangle it while others refuse the download. The reference is reduced to letters, digits, dot and dash, runs collapse, and the payment id is the fallback when nothing printable survives. Proven by fetching the header: recurro-evidence-MND-0043-Aug-run.json.
f0592890d7 - Receipts: one button that works, in place of two that did nothing
The receipts screen offered two buttons with no handler. "Q3 evidence bundle" sat beside a bundle route that has existed for weeks and takes the payment id, which is exactly why paymentId was put on the receipt data and then never used. "QuickBooks export" promised an export that has never been built. A button that promises a feature and does nothing is the receipt page saying "signature verified" for any id all over again.
9c7bd140ff
2026-09-02
- Money view: what we hold, and whether it covers what is coming
One sentence at the top of the overview in place of two tiles that answered questions nobody asked. Three horizons, a timeline of what leaves and when, held payments marked possible and never summed as certain, a shortfall turned into the deposit button with the figure filled in, holdings carrying their block and age, balance told apart from spendable-at-once, and currencies never added across.
95d9a3ce23 - Money view: the design pass
Brainstormed before building, grounded in what the system can actually know. The private balance can only be computed in a browser holding the passphrase, so the view splits into two halves with different trust: what is coming, which the server knows always, and what we hold, which the browser knows once unlocked. That is the shape of the view rather than a limitation to design around.
9eae5c1e01 - Audit RFP, and the road to release as tasks
The RFP is written from measurements rather than recollection: scope file by file with line counts, the circuit parameters, and the 16-output variant we want inside the engagement rather than audited afterwards as a change to an audited contract, with the opcode measurements so the sizing is not guesswork.
907b55496d - Scope the payroll payment_id write, which a guard caught and I nearly shipped
run-guards.test.ts asserts every write to payroll_run_items carries the caller's organisation in its own SQL. The payment_id update I added did not: the id came from an org-scoped update a few lines above, so it was safe today and would have stopped being safe the first time somebody reordered them. That is exactly what the guard is for.
479c875269 - Close the loose ends, including a guard that was never being run
Asked whether the problems were solved. They were fixed and logged, and checking turned up four things left hanging, two of which quietly undid the work.
43a958e67b
2026-09-01
- One preflight, and the misattribution it uncovered
I claimed the preflight warned when a payroll payee has no expense account. It did not. needCoding was computed and never rendered, so the sentence described a feature that did not exist.
aaeaa32f04 - Payroll into the books, and a guard so the class cannot recur
markItemPaid updated payroll_run_items and returned. No payment row, no receipt chain, no rail_submissions row either. A run that paid forty contractors was absent from the journal, from tax-year vendor totals, from the confirmations view and from the close checklist, and the month closed cleanly reporting that every settled payment was coded while forty payments were not in the books at all.
2e4c68cd65 - Reset the close panel by key, not by an effect
The effect added yesterday to clear the stale reopen form called setState synchronously, which is a cascading render and one of the five errors standing between the repo and an eslint gate. React's own answer to "reset local state when a prop changes" is a key, so the panel is keyed on what the server says about the month. Closing or reopening remounts it and useState starts fresh.
7348fc6a2b - Adversarial audit: a closed month was not actually closed
Seven findings against what the last two items shipped, five of them real defects. The two largest were both controls that looked like they worked.
52ddae0ced - Close a month, and mean it
A close is two promises: that these books have been agreed, and that they will still say the same thing tomorrow. Recurro could do neither. You could look at September through a pair of dates in a query string, but nothing recorded that the books had been signed off, nothing stopped them changing afterwards, and nothing could answer the auditor's question, which is not "what do the books say" but "what did they say on the day you agreed them".
4ce48903b2 - Rename the demo cast, and fix the three guards that renaming exposed
The names were a tell. Every person in the seed was "X. Surname" and the surnames toured one country each: NG, HU, GB, ZA, NO, US, HR, NG. The companies toured legal suffixes. The org was Northwind Labs, which is Microsoft's canonical demo database. Seventy-five filler payees were "Payee 001" upward under a comment saying they were never to be individually seen, which stopped being true the day the Confirmations screen listed payees by date instead of by amount.
36c03b776c - Two people, one payment, two different answers: fixed
Attacking what had just been committed found three things, none in the cryptography and all of them only visible by walking the flow.
0a3a0930a5 - Payees prove they were paid, because the payer structurally cannot
The queue asked for an unclaimed state and a way to recall a note. Neither is buildable, and finding that out corrected the note: a nullifier is H(DS.NF, nk, cm, leafIndex) and only the payee holds nk, so the payer cannot see whether a note was claimed and cannot spend it back. That is the privacy design working. The cost of it is real, though: pay a wrong address, or pay somebody who lost their passphrase, and the money is gone while the books say paid.
41a2162208 - Audit tax identity: three wrong numbers on a filed return
The output is a figure somebody files with a tax authority, so it got attacked the same day it was built. Three of the four findings were arithmetic that gave the right answer on the seeded data and the wrong one on data containing a retry, a correction, or a second currency.
1fd50fc4ec - Vendor tax identity, and the number we refuse to hold
The second Tier 2 item. /tax carries what each vendor was paid in a calendar year and whose form is still missing, with a file for whoever files the returns.
4d0392a658 - Audit change control: the two-step that walked straight past it
A fraud control that has not been attacked is a control nobody has tested, and attacking this one found that it was decorative against the obvious attack.
6080db511b - Change control: redirecting a payee needs two people
The first fraud vector in accounts payable, and the one controllers name without being asked: a mail arrives saying the bank details have changed, somebody updates the record, and the next payment goes to a stranger. The audit log recorded it afterwards, which is evidence for the investigation and no help to the person about to be defrauded.
b6362e632d - Audit the run layer: the lease, the resume race, and a stuck approval
The batch payroll executor and its hooks were new money-moving code with a happy-path devnet test, which is exactly the combination that deserves an adversarial pass before anything builds on it. Four findings, each proven against the real schema in a rolled-back transaction before fixing and proven closed the same way after.
04d1096811 - Batch payroll: the run layer, and three chained payments proven in 19 seconds
The last Tier 1 item, built to the design and verified against a devnet rather than believed. One deposit and three chained payments, real proofs, the real HonkVerifier: 19 seconds end to end, each payee's wallet opening exactly its own note with its own memo, the treasury left with exactly its change.
ddefa28e04 - Batch payroll design: measured, and the intuition was backwards
The design pass for the last Tier 1 item, run with nargo and bb.js instead of opinions. The decisive measurement: outputs are nearly free and inputs are not. Every variant up to 16 outputs lands in the same 2^17 proving bracket as the deployed 2-out circuit, about 500 ACIR opcodes per extra output, so a 16-output proof costs what a single payment costs today and the hosted CRS already covers it. Two extra inputs cost a depth-32 Merkle path each and push the circuit to 2^18, doubling proving time, which is why fragmentation stays the consolidation flow's job and never the circuit's.
076599b615 - Finish the matrix: ranked roles, and a preview before you activate
The two things flagged as not done, which was not a reason to stop.
9aa1aafaca - The approval matrix, and a dashboard that answers "is this waiting on me"
Bands of amount, each naming how many people and whether one has to be an approver, plus delegation for when somebody is away. The approver role existed in the schema with nothing to enforce and holds.second_approver was a text column that could only ever record one name, so a rule asking for three had nowhere to put the third.
37393278ec - Audit the fix: report the third disagreement, and open the wall
The fix for the journal was new money-adjacent code written fast, right after a trust failure, which is exactly when mistakes cluster. Three things checked, two of them worth fixing.
78917deb3a - The journal is built from evidence, and refuses when there is none
Logging a books-are-wrong finding instead of fixing it is exactly what makes a tool untrusted. Fixed, and the root cause turned out to be worse than the thing I logged.
d17cd06a57 - Build notes: the audit of the ledger and the earnings statement
Six findings and one non-finding, with the reversal gap in the ledger recorded as open rather than quietly left: a payment settled in one period and reversed in a later one drops out of the journal entirely, and if the first period was already exported the books overstate with nothing here to correct them.
7285a0eace - Adversarial audit: the earnings statement was counting a payee's own change
Five findings on this morning's work, and one of them is the worst kind: a wrong number on a document somebody files.
c5c0d0d860 - What you earned: the statement for the person who is not a finance team
Getting paid privately worked. Proving what you earned did not. A freelancer could see a balance and move money out and nothing else, so the only way to answer "what did I earn this year" was to move everything to a public wallet and let a block explorer count it. That is a real cost to somebody at tax time, and it is the pool's own loss twice over: the note leaves, and it leaves for a reason we could have removed. Every note that stays in is another person in the crowd, which makes the small holder the moat rather than a lesser customer.
f5e9f2df5a - The ledger export, so Recurro stops being a second set of books
The first of the three items that block a controller adopting this, and the cheapest. A payment carried what was paid and to whom and nothing about where it lands in a chart of accounts, so every one of them was re-keyed by hand into the real book. That does not make Recurro a feature short, it makes it a second book of record to reconcile against the first, which is the thing a controller is paid to prevent.
e616e6d269 - Drop provenance from publishConfig, because it would fail the publish
npm generates a provenance attestation from a CI OIDC token, so setting it means a publish from a laptop errors instead of publishing. It is worth having, and it belongs with a GitHub Actions release workflow rather than in a field that turns the first publish into a failure somebody has to debug. Noted in the queue next to the rest of the missing release path.
067efe2b90 - Prepare @recurro/protocol for a 0.1.0 publish
Version drops from 1.0.0, which npm init wrote and nobody decided, to 0.1.0, which is the honest number: the note format is not frozen, and a change to the commitment layout or the key derivation changes what an existing wallet can find. The README says so, and says the cryptography is unaudited, because a version number on a package that touches money is a promise about stability.
0e2f99833f - The facilitator settles where the payers are, not where the pool is
The network was one constant, eip155:84532, wired into nine places. That was never a decision about reach; it is where the first pool happened to land, and it left the facilitator unable to take a payment from Ethereum, which is where people actually hold stablecoins.
a09e47fb63 - Adversarial audit: six findings on the last three commits
The meter disagreed with the limit it reports on. /api/x402/usage counted the hour with the billing filter, excluding rows whose transfer never landed, while the settle route counts every row in the hour. Measured against the schema with one row in each state: it enforced 5 and reported 3. So a caller could read "remaining" and be refused at it, which is the one thing a meter must not do. The hour is now counted the way it is enforced; the month keeps the billing filter, because a transfer that never landed is not something to invoice for.
75941dead4 - The pool can hold USDT, and still refuses the tokens it should
The pool read `if (!token.transferFrom(...))`, which is the obvious way to write it and cannot move USDT even once: USDT's transfer returns nothing at all, and decoding a bool from empty returndata reverts. The largest stablecoin in the world was undepositable and unwithdrawable, and nothing would have surfaced that until the day we listed it, because the only token ever tested against was a well-behaved mock.
2f8db8df23 - The facilitator knows who is calling, and counts it
The agent rail was anonymous, so there was no way to bill it and no way for a caller to see what they had used. api_keys and the settlements table already carried everything needed; nothing was wired to either.
5595351eed - A wait budget that counts only the time somebody was watching
Both waits in the product used a wall clock deadline with timer driven polls. A hidden tab throttles those timers roughly sixteen fold, degrading to one tick a minute after five minutes, so the clock ran out while the polls barely happened. Measured on production: compute is not throttled at all, so the proof itself finishes at full speed in a background tab. Only the waiting stalls.
3fae8a3a00 - Build notes: the resident host is an optimisation now, not a blocker504e6eb152
- A unit test wrote to the production database, and now cannot
Persisting after every sync meant the reorg tests, which drive a fake chain, saved fictional snapshots into whatever database the environment pointed at. They did, into the real one: a row under the literal pool address "0xpool". Harmless, because loading is keyed by address and no real pool will ever ask for that one, but a test that can write to production is a test waiting to.
c57bfd0d00 - The indexer writes down what it learned, so the next runtime starts warm
Every serverless runtime rebuilt the tree from the pool's first block on its first request: measured at seven seconds against production, growing with history, and paid by whoever happened to land on a cold instance. I had written that up as blocked on provisioning an always-on host. It was not. The chain's history does not change, so it can be written down.
6d299c8f71 - The mark leads to the product on every host, and a check keeps it there
Clicking the logo on pay.recurro.xyz left you on pay.recurro.xyz. The navbar I built pointed the brand at "/", which on that host is the payee landing, not the product's front page. This is the second time: on the app host it pointed at "/" too, which redirects to /overview, so the logo inside the demo was a loop with no way back to the landing page. Both were found by somebody clicking it.
77614b4929 - Build notes: the passphrase edge is closed where it can be
Generation is fixed outright. Typed entry stays advisory on purpose: blocking a weak passphrase would strand a wallet somebody already owns.
b4ef5964cf - A voucher already spent is not a voucher still waiting
Making the claim idempotent stopped a browser failing mid-flight from burning somebody's welcome payment, which was right, and it handed the same voucher back when the first attempt had actually worked, which was not: the note was gone, so the visitor was told "insufficient balance: have 0" about money they had already received. Found by walking the flow on production rather than by reading it.
604b9c8012 - Stop handing out one-in-a-billion passphrases
Our own generator was the weakest thing in the product. Six words drawn from a list of thirty two is 2^30: brute-forceable on a laptop, and two visitors colliding by chance somewhere around thirty thousand of them. It sat behind the landing page's first button, holding money. Twelve words from the standard 2048-word list is 2^132, which nobody reaches.
3109055853 - Build notes: drop a register row the nonce manager already closedd3f318aac9
- Build notes: the audit table has nothing open left6ba4477f75
- Close the audit's last four, one of which was not a bug
Spurious rebuilds: one endpoint fronts a pool of nodes, and a replica a little behind answers with a different hash for reasons that are not a reorg. A rebuild throws away a correct tree, spends the request budget a cold start needs, and leaves the instance answering nothing while it runs, so it now takes two consecutive disagreements. A real reorg is still caught, a moment later. The tests assert both halves, including that agreement in between clears the count.
db67feef34
2026-08-31
- Build notes: the barrier shipped, four audit items still openafa2b71350
- Type the test's fetch mock so its calls can be inspected
The build refused the untyped one, correctly: the assertion that we never put a nullifier in a URL was reading index zero of an empty tuple.
00fab42153 - Combining waits for the pool between steps, instead of racing itself
Each step of a consolidation spends the note the step before it produced, and the loop started the next one the moment the relayer accepted the last. The next send rebuilds its whole view from the indexer, which had not seen the previous payment yet, so it picked the very notes it had just spent: one click, on one device, refused as a double spend "most likely from another device". Making the indexer stay five blocks behind the tip widened that window from about two seconds to about twelve, which turned an occasional race into the usual outcome.
d8358675bc - Build notes: the audit, its fixes, and what it left open
Twenty two findings, sixteen fixed today. The five still open are named with what to do, and Consolidate's missing barrier is the next thing to build.
3ae79b4dcf - Close what the adversarial audits found, starting with a live drain
Two independent reviews went at the money paths. The worst finding was mine to have caught: the sweep paid out against transfers that had not landed.
80b62beda2 - The facilitator stops taking the payer's word for the token's own domain
Found by signing a payment over an EIP-712 domain of my own invention and watching /verify agree. The domain's name and version were read out of the caller's payload, so anyone could produce a signature that verified here and could never settle on chain: no money moves, but a resource server that trusts verify before doing its work hands over the goods and is never paid, which is precisely what verify exists to prevent. The domain is now read from the token itself and cached, so the only signature that verifies is one the token will also accept.
8d2e199942 - Build notes: reorg tolerance shipped, indexer residency blocked on a host
Cold-start measured at 7s against production, past the trigger written into the item. The work is small; the always-on host is the founder's to provision.
e055ed6131 - The indexer notices when the chain changes its mind
It only ever moved forward. A reorg keeps block numbers and changes their hashes, so every leaf indexed from an orphaned block would have stayed in the tree, and every Merkle proof served afterwards would have been against a root the chain never had: the payer waits through proving and then watches it revert, with nothing on screen able to explain why.
a06f70f30d - Build notes: nonce manager shipped, indexer residency is next8e10b3dcc4
- blockTag 'pending' is the chain's word, not ours
Same exemption the rule already grants lib/rail.ts for the same reason.
f3396d41ca - Postgres hands out relayer nonces, so two runtimes cannot spend one
viem asks the chain for the next nonce at broadcast time, which is correct until two serverless instances ask within the same moment: both get the same answer, both sign, and the second replaces the first. The caller of the replaced payment was handed a transaction hash for something that will never exist. Two features now sign with this key, the pool relay and the x402 facilitator, so the race stopped being theoretical.
69d58b2101 - Build notes: facilitator shipped, with what it still owes
Metering, a seller-side example, a third-party client test, float top-up and reconciliation of unconfirmed settles are all named rather than implied by the tick.
6dd564fd1d - An x402 facilitator that delivers the payee privately
The agent rail, built against the published spec rather than a memory of it: x402 v2, exact scheme, EIP-3009 transferWithAuthorization over EIP-712, the network spelled eip155:84532 and settling the same Base Sepolia USDC the spec's own example uses. /api/x402/supported advertises only what has settled, /verify answers with named reasons, /settle broadcasts the transfer exactly as signed, and /settlement/{id} reports both legs.
75d4263a0f - Build notes: pulse shipped, facilitator is nextaba7639fdf
- The pool publishes its own pulse, and stops calling markers payments
recurro.xyz/pulse: the anonymity set charted from chain data, because the size of the crowd is the honest limit on what privacy is worth here and publishing it while it is small is the same rule the product follows everywhere else. Server-rendered inline SVG, no charting library and no client JS; the indexer gains a per-event activity series bucketed by day from block numbers against one fetched anchor timestamp, served at /api/pool/series so the chart can be checked against its own source.
9a58c3b938 - The build queue becomes a file instead of a conversation
docs/build-notes.md: the ordered queue with how-to notes per item (pulse page, x402 facilitator, nonce manager, resident indexer, reorg tolerance, the paper layer, then the ladder), the founder's manual-test checklist, the edge-case register, the loved-app bar, and a dated shipped log. It also records the multi-asset standing decision: anonymity does not bridge, so it is one pool, one chain, one asset until a design partner brings committed volume on a specific other rail; USDT's non-standard ERC20 handling is listed as cheap preparation for that day.
81f7515a6b - icon.svg joins the files every host serves as itself
Same gate that once ate the circuit: a file all three surfaces need has to be on the shared list or the payee host rewrites it into a 404.
9e6bcd6cf3 - The tab shows our mark, and the wordmark sits on its line
Every host was shipping create-next-app's default favicon: the one pixel of the product most often on screen, and it said someone else's name. The ring mark from the logo is now the icon, as an SVG and as a hand-packed ICO rasterised with a distance-field so the small sizes stay crisp.
f08f7ca559 - The indexer joins the technical-surface allow list
Its ABI strings name the protocol's own events; the vocabulary check exists for reader-facing copy, and the last commit shipped past its failure because a tail in the pipeline swallowed the exit code. Clean again, and noted: guard scripts belong before the && chain, not piped through tail.
63e9b5e293 - The payee host gets one navbar and one footer, like a site
The chrome dated from when this host was one screen per payment link and the payer was the whole context: a small mark in a corner, footers improvised per page. Six destination pages later, the layout now carries a navbar (brand home, the payee's five places with an active state, one exit for payers) and a single footer with the network truth stated quietly. The per-page footers go; the payer's name remains a property of the payment, stated by the screen that has one.
55e3259400 - Claiming a voucher is idempotent, so a failed send strands nothing
The first dogfood run claimed a voucher, failed at the send, and was then refused for a day while the voucher sat burned: a one-way claim turns every browser-side failure into lost inventory and a locked-out visitor. A repeat claim within the day now returns the same passphrase. No new inventory is spent, a failed send retries cleanly, and a visitor whose payment already settled gets a voucher whose reuse the pool itself refuses.
c755459495 - The opening scene gets staged instead of parked in a corner
The landing's first button led to a lone card in a void: no orientation, the proof reduced to a button label, and the ending a paragraph. The page now carries the payee surface's editorial register: a hero that states the promise, a three-act rail that lights as the visitor moves, a proving stage with an elapsed clock and the pipeline ticking (the zero-knowledge proof on their own machine is the argument, so it gets the stage), and a closing card shaped like the note they now hold, with the pool's live note count placing theirs among strangers'.
77023bb242 - Minting waits for the indexer instead of racing it
Sequential mints scan the pool each time, and the change from one send is only spendable once the indexer has seen it: the script now reads the leaf count before sending and waits for it to advance after, which took the first production batch to fifteen for fifteen.
631ed6aa14 - Get paid privately in one minute, and the plan for charging money
The flywheel's engine: pay.recurro.xyz/try hands a visitor one pre-minted voucher note, and their own browser makes a real private payment from it to a six-word passphrase generated on the spot. They watch their machine prove, see the transaction that shows nothing, and leave holding money only they can read. No server proves, no server holds a key that matters: vouchers are minted locally (scripts/mint-vouchers.mjs) through the production relay, the claim is one atomic UPDATE with one-per-IP-per-day, and an emptied pot says so instead of failing. The landing's first button now offers it.
10442e57bc
2026-08-29
- Event signatures match the contract, indexed-ness included
Decoding is stricter than filtering: topic0 forgave the wrongly-declared indexed parameters for as long as the indexer filtered by event, and the single-request rewrite surfaced the lie as zero leaves beside four deposits. The declarations now copy Pool.sol verbatim.
9cd77dcbfb - One request per chunk, and a backoff instead of a dead API
The stats work tripled the indexer's getLogs traffic and the public RPC answered "over rate limit", which took every pool endpoint down on cold start: the exact outage mode the chunking comment warned about, arriving from the request count instead of the range. The sync now fetches each chunk unfiltered in a single request and decodes locally (the pool emits few events, so this is free), and a rate-limited answer earns a doubling backoff rather than a 500.
067ce1843a - The pool reports its own smallness, the landing stops apologising for a fixed bug, and the threat model exists
Product-led evidence, first installment. The indexer now tracks what deposits already publish (funder addresses, counts, activity blocks) and serves it at /api/pool/stats; the Overview anonymity tile reads it and says the truthful thing: how many funders, how many notes, and that a small set is weak privacy. Growth of that number is the metric, so now it is measured where everyone can see it.
2dc48a50df
2026-08-26
- One glyph for an empty cell, and it is not an em dash
The sweep left two placeholder conventions behind: most screens moved to the en dash while a few kept the em dash as a unicode escape, which still renders the banned character and, in one place, compared against a value the queries layer no longer produces, so payroll deltas would have styled wrong. Every empty-cell placeholder is now the same en dash.
1f4c1d4401 - Every em dash goes, and a check keeps them gone
The rule is absolute and the tree was full of them: 295 lines in web/src, hundreds more in docs, scripts, contracts and the SDK. Visible copy was rewritten by hand, sentence by sentence: split in two where the dash hid a full stop, a comma or colon or parentheses where the structure genuinely called for one, never a bare hyphen swap. Placeholder glyphs in tables (a dash standing for "no value") became en dashes, which are data, not prose. Comments and internal docs were cleaned mechanically. copy-check now fails on any em dash under web/src, so the next one is caught at commit time, not by the reader.
61f2281f35 - The payee learns what each payment was for
The memo travels encrypted in the envelope and was decrypted on arrival, then dropped: ScannedNote never carried it, so no payee screen could answer the one question an invoice-paying counterparty always asks. Found by paying a published request and watching its "Invoice 2026-001" vanish. The scan now keeps the memo (the payer's own words, authenticated as part of the sealed payload, asserted like any invoice line), and the payee's unlocked view lists each note with what it was for beside what it is worth, a record you can reconcile, not just a balance.
cd7f9b8d78 - Position for teams and their software, and let a payee publish a charge
The repositioning decided today, executed. The hero reads "Pay your team, and your agents, without publishing what you pay"; the agents section moves above the fold and opens with the argument that carries the category: an agent's payment stream is strategy, published in real time if it settles publicly. Payroll stays as the proof point everyone understands first. The status block invites the two spearheads by name, finance teams, DAO and protocol treasuries, fleets of paying agents, and the positioning doc records the decision and what was ruled out.
252f519345 - The payee landing learns who else pays, what to keep, and what to fear
Four gaps from the audit, closed with what is true today and nothing more:
429533d4d7 - The payee landing gets designed instead of apologised for
The front door was one card floating in a void, functionally honest, visually nothing, and the first thing a payee ever sees of Recurro. It is now set in the product's own system at the marketing page's register: an editorial hero, the three steps of getting paid as a numbered hairline table, a specimen card showing the one object unique to this surface (marked Specimen, the no-invented-figures rule stands), a quiet facts strip, and one staggered arrival animation that base.css already stills for reduced motion.
f0ca5119b9 - The payee host serves the prover's own parameters
Proving on pay.recurro.xyz died with "Could not load the circuit (404)": the host rewrites unknown paths under /pay, and that swallowed /circuits, /crs and the CRS service worker, the exact files a proof cannot start without. The shared-asset list already named them; the rewrite just never consulted it. It does now, through one exported predicate instead of a second copy of the list.
e3fc0b0182 - The payee host serves the pool API it asks its visitors' browsers to call
Unlocking on pay.recurro.xyz/move failed with "Failed to fetch": the screen told the browser to read the pool from the app host, and a cross-origin call with no CORS is refused before it leaves. Node-side tests call the API directly and can never see this; only a real browser on the payee host can. The same app serves both hosts, so the pool API now passes through on the payee surface and the move screen calls it same-origin, no CORS headers to maintain, and the relay POST stays first-party.
78a33688d9 - The payee front page passes its own gate
The new landing rendered only in test mode: the layout's live-mode guard shows the gate for any path not on the stands-on-its-own list, and the root was not on it. It shows no figures and needs no link, which is the whole test for that list.
3215199b5f - The payee host gets a front door instead of a refusal
pay.recurro.xyz once invented a payment for anyone who typed the address; the correction showed nothing at all, honest, and a dead end. The payer app tells payees to open this host to generate their receiving address, so the person who types the host instead of the exact path was met with "there is no payment to show here" and a product-page link that looped back to the same page. The root now offers what a payee can do without a link, generate their address, move money out, read what receiving privately means, and still never renders an amount that did not arrive by link. The gate's product-page link now actually leaves for the marketing site.
cd496a5cf0 - Test mode gets to do the thing the product is for
Test mode offered the deposit into the real testnet pool and totalled a real private balance, and then hid the private send behind env === 'live', on the strength of a comment claiming test mode has no pool. It has had one for days. A demo where you can fund privacy but never watch a private payment happen demonstrates everything except the point. The pool panel now appears whenever a pool is configured, exactly as the comment beside it always claimed it did.
d31c6c1c13 - A wallet hiccup at submission costs a click, not another proof
The first depositor's wallet failed twice at the last step, "over rate limit", then "could not detect network": the wallet's own testnet RPC, after the proof had already been generated. The flow threw the proof away and made them start over, proving included. Preparation (build, prove, encode) is now separate from submission (wallet, chain), the finished proof is kept when only submission fails, and the button becomes "Send again", straight to the wallet. The proof binds a root the pool keeps recognising and nullifiers only this note owner can produce, so resubmitting the same bytes is safe; the kept proof is discarded the moment the amount or passphrase changes, because it is a proof of exactly those.
b977b674a8 - The private balance stops counting money that was already spent
The balance screen scanned correctly and then filtered nothing: it asked the pool about each nullifier in a format the pool does not store, every answer came back unspent, and the total kept every note ever received, after the first payment it overstated the balance by the amount of every payment ever made. The per-note questions were also a leak: asking about one nullifier tells the indexer that nullifier belongs to somebody scanning right now, which is the correlation the pool exists to prevent. It now uses unspentNotes, the same bulk-set filter every other balance-bearing surface already uses, which also drops the zero-value change notes from the count.
db39fbcb71 - A browser that cached the short CRS never asks for it again, so change the question
The prover fetches its parameters with cache: 'force-cache', which reuses any cached response without revalidating. Every browser that attempted a proof before the hosted CRS grew has the short file cached, and would keep receiving it from its own cache forever, the server fix alone cures only browsers that never tried. The service worker now maps CRS requests onto a versioned local URL; bumping the version makes every browser fetch fresh because the poisoned entry lives under a key nobody asks for anymore. Registration also checks for an updated worker even when an old one already controls the page, since the mapping the worker applies is part of proof correctness.
5101392208 - The hosted CRS grows to what the prover actually asks for
First deposit attempt from a real browser: "SrsInitSrs: invalid points_buf size. Expected 32 or 64 bytes per point, got 8." The chain of custody: bb.js 5.2.0 requests 2^19 CRS points by default; the service worker routes that request to our self-hosted copy (correctly, a CRS download tells a stranger's CDN this browser is about to transact privately); the hosted file held 2^17 points from an earlier bb.js; a byte range past the end of a file returns the whole file; 2^17 × 32 bytes over 2^19 points is 8 bytes a point, and the prover died before the first proof. Node never saw it because node reads a complete local CRS.
f0a47f8b5b - The app fits a phone, and the chooser stops offering the same person twice
The shell had no breakpoint at all: on a phone the 214px sidebar stayed at full width and the content column got what was left, about half the screen, every word wrapping at two syllables. Below 820px the shell now stacks: brand and account on one row, the nav as a horizontally scrolling strip, the main column full width.
11552f8d14 - The deposit form gets a place to stand instead of a header to collide with
The Deposit component lived in the topbar's actions slot; closed it was a button, open it became a 620px form fighting the environment toggle for the same row and losing, clipped under the content, overlapping the controls, the first thing a signed-in operator saw. Opened forms belong in the flow of the page: it is now its own "Fund the pool" section at the top of the Overview column, a slim explaining row that expands in place.
acdff74620
2026-08-24
- The prover speaks the chain's language, and the pool holds its first real money
The browser prover called generateProof with no options, which in bb.js means a poseidon2 transcript, a proof flavor that verifies inside bb.js and nowhere else. Every mock-verifier test passed; the first transaction against the real on-chain verifier reverted with ProofLengthWrongWithLogN. The fix is one option, verifierTarget: 'evm' (keccak transcript + zk), and a generator script that emits the Solidity verifier from the same library with the same option, so the two sides cannot drift apart again. bb.js is pinned exactly for the same reason the option is now explicit.
1a00a8e067 - The sidebar stops advertising a page that hides itself
The solvency page calls notFound() until publishing pool figures stops being a leak, the landing page respects that flag and hides its link, but the app sidebar showed "Pool" unconditionally, handing every user a guaranteed 404 dressed as a navigation item. The nav now takes the same flag the page reads, so the link and the page appear together or not at all.
b3a946dbd0 - The pool is live on Base Sepolia, and the app is its own indexer
The last piece of infrastructure between "works on my machine" and "a link a stranger can click". The pool is deployed to Base Sepolia with the real HonkVerifier (36KB of generated verifier, not the mock), the Poseidon hasher whose on-chain output was verified byte-equal against poseidon-lite before anything was wired to it, and Circle's test USDC as the settlement asset, the same token the agent rail pays in, fundable from Circle's own faucet, because "get test USDC, deposit it, pay privately" only demonstrates anything when the USDC is the one everybody on the testnet already has.
8d26ee89e4 - A 404 with a way home, and a deploy tool that deploys to more than the devnet
The middleware's host guard fires before Next's routing, so the branded not-found page never had a chance on a cross-host miss: the guard returned a bare empty 404, a dead end with no body at all, the exact pattern the UX audit was about. It now returns a small branded page saying what happened, that nothing about the reader's account is affected, and where the three places they probably meant are.
880abec55b - Every road leads somewhere: the navigation dead ends, and the pool takes real USDC
The UX audit's finding generalised. The brand mark in the app sidebar linked "/", which this host redirects straight back to /overview, so once inside the demo there was no way to reach the landing page at all. The same loop on the payee surface (where "/" is the payee gate) and the sign-in page. All three now go to recurro.xyz.
c7310dfa2b - The agent names and standards, visible where a visitor actually looks
The audit's finding: the vocabulary was ported and the surfaces were not. The landing page contained zero mentions of agents, a shipped differentiator absent from the pitch. The Agents screen showed a mandate count and no protocols. And the four protocol mandates hung off a leftover fixture agent, so the registry told no story at all.
e3125fce0b
2026-08-23
- Live mode is the product, not a brochure
The gap between "demo" and "proper app" was one sentence: live Send returned "not built yet" and refused everything. The decision path has been environment-agnostic the whole time, the same recordDecision serves the API's live callers, writes real receipts, opens real holds, checks real mandate caps, and issues a signing template when the rail conditions are met. The refusal was a leftover from before any of that existed. Removed: a live operator's payment now runs through the same engine as everything else, with the private pool panel appearing above the form whenever a pool is configured.
6c108e2a0d - The docs page and the protocol registry the demo was missing
cartulary.xyz/docs existed and Recurro had nothing at that address: no public reference for what the product is, how an approval relates to money, or how the API and signing flow work. And the demo showed none of the agent standards, the protocol enum, notes and labels were all ported, but every seeded mandate was 'direct' and no screen displayed a protocol at all. The model's whole argument, one shape expressing x402, ACP, AP2 and MPP alongside direct grants, was invisible.
f7cb2b0386 - The agent layer, adopted from Cartulary: external signing, bumps, and the sweep
The agents question had a sharp answer: only the schema was ever ported. rail_submissions existed with nothing writing to it, agents had bound wallets nothing used, and the flow that made Cartulary's agents real, the server issues an unsigned transaction, the agent signs exactly those bytes, the server relays, was absent entirely. Adopted now, whole.
b61a999658 - testnet-up: source the environment before choosing the chain
.env.local carries RPC= for the local devnet, and sourcing it after selecting the testnet RPC let the local value win, so the script asked anvil for a Base Sepolia balance and reported a connection error on a chain it was never talking to. Source first, choose after, with a comment saying the order is deliberate.
03d7a2bcad - The front door: anyone can now become a customer
The product audit's first finding, and the worst one: sign-in worked, and then every journey ended at "ask whoever set up your organisation to invite you", advice that assumed a thing no code path anywhere could create. insert into orgs existed only in seed scripts. The funnel was demo → sign in → wall.
ade640b0d9 - The record you can actually browse
The sidebar's Receipts link pointed at a hard-coded receipt id left over from the mockup, a payment that exists for nobody real. So on a product whose entire proposition is the evidence, /receipts was a 404 and the evidence could only be reached by somebody who already knew the identifier of the thing they were looking for.
85e903304f - A payment says what it was calculated from, and which invoice it answers
The two questions an audit asks that an amount cannot answer on its own. A payment recorded that 4,200 was authorised and sent, and nothing about why it was 4,200 or what document it settled.
7601c0e138 - Two questions an audit asks that a bundle cannot yet answer
A payment records an amount and not what the amount was calculated from. A bundle can prove 4,200 was authorised and sent, and cannot show why it was 4,200, the rate, the quantity, the currency the contract was written in, or what a stablecoin was worth in it on the day. That belongs in the receipt payload, where the hash and the signature already cover whatever it holds.
e1b03c0713 - The evidence bundle carries the authority, not just the payment
An audit asks who authorised a payment, against what, for how much, when, and where that is written down. The bundle answered three of those. It named the payer, the payee, the amount and the date, and left the reader to take on trust that anybody had authorised it, the mandate was stored, joined to the payment, and never exported.
0e7c7d2f49 - Threaded proving: two explanations tested and ruled out
Finding 17. The prover asks for four worker threads, one loads, three are aborted by the browser, and it falls back to a single thread, 2.5 seconds becomes about twenty. bb.js creates them with Promise.all over a single URL, so every worker fetches the same module script at the same instant, which made two explanations obvious enough to be worth eliminating properly rather than guessing between.
99a4c3629f - The observer panel is chain output, not typed
Finding 12. The comparison on the landing page, an ordinary stablecoin transfer beside the same payment through the pool, was hand-written JSX. The addresses, the 12,800.00, the commitment and the nullifier were literals somebody chose, on the one page whose whole job is to say we do not make figures up. D-16 always specified real output from sdk/observer.mjs and the acceptance line always said regenerated rather than pasted.
71469a3829 - Recovery, consolidation, and a balance that was overstated
Findings 05, 09, 10 and 16.
51d1e76823 - Standing mandates that actually stand, and a scheduler that runs them
Findings 04 and 06 of the claims audit. "A standing mandate authorises a payee, a cap and an expiry once. Changing any of them needs a new signature" and "payouts run" were on the landing page while nothing could cause a payment to happen. A mandate could permit one; there was no cadence, no scheduler, and no signature.
8796dd725b - Measured figures, the gates on the public roadmap, and documents that match the code
Findings 07, 08, 13, 14, 15, 17 and 20 of the claims audit.
d206f03ca0 - Disclosure keys that actually scope
Finding 03 of the claims audit. The landing page offered "a key scoped to a date range and a set of counterparties, they see exactly that", the Disclosure screen repeated it, and the roadmap said the feature was years away. The scope existed only as a sentence somebody typed: guest_links was seeded and never issued, never redeemed, never enforced.
0d7ba01d98 - The freeze can be given up, and the page says whether it has been
Finding 02 of the claims audit. pay.recurro.xyz told payees "Recurro cannot move it and cannot freeze it" while Pool.sol line 179 gated every transact, withdrawals included, on an allowlist the owner controls. Set relayersOnly and empty the list and nobody can move anything, including a holder taking out their own money. Nothing on the site mentioned the owner key at all.
bb06588fbf - Receipts are signed on the payer's device, and check without us
Finding 01 of the claims audit. The landing page said "a receipt signed by the payer" while /standard said, in as many words, "until they exist no page here claims a receipt is signed", and the public roadmap carried the same false sentence marked shipped. The fix is the feature, not the retraction.
bbf3fb81ef - Moving to a public wallet, and a privacy score that is computed rather than claimed
This closes the other end of the loop. Money can now get out of the pool: proved in the browser, submitted by the relayer, confirmed on a devnet, with the recipient's balance rising by exactly the amount less the fee and the change staying unreadable to anyone else.
b2cded9408 - Deposit: money can get into the pool
The loop was open at both ends, a payer with no balance could not send, and there was no way to get one. This closes the first end. Built, proved, encoded and confirmed on a devnet, with the note readable only by the key that deposited it: a second wallet scanning the same pool sees nothing, which is the property the whole design exists for and is now asserted rather than assumed.
63a06fb77b - A private payment, made entirely in the browser
75.00 sent to a payee on a devnet, and the payee read it with their own key, memo and all. Notes chosen, proof generated, note encrypted to the payee, every step on the payer's device, because every step needs a key that never leaves it.
8f357bb8ee - The host split 404d the proving parameters in production
Everything for browser proving worked locally and returned 404 on the deployed hosts: the circuit, the CRS files, the service worker. pathAllowed decides which screens a host serves and had no opinion about assets, so anything the browser fetches rather than navigates to was refused.
c915ee6a74 - Proving runs in the browser
A real UltraHonk proof, generated on the device: 18.17 s, 14,656 bytes, 11 public inputs, the same size as the proof Node produces, because it is the same circuit. Node does it in 851 ms with four threads, which is the gap to close, not a different result.
8d4e3a70fa - No invented approver, and requiring two is a choice
Two things wrong with what I shipped.
3f1fd5ee4d - Enforce the three controls that were displayed and not enforced
All the same species of defect, and worse than a missing feature: somebody would rely on them.
c1a141b096 - Evidence bundles, and the format published so somebody else can check them
The receipt panel showed a fabricated terminal transcript: "3 receipts · signatures valid · chain unbroken". Nothing was exported, nothing was checked, and it claimed signatures that do not exist. Export bundle is a real download now.
43bba26070 - Port the rest of the control plane: policy lifecycle, revocation, investigation
Policies can be changed now, in two steps. A draft is written and decides nothing; activating it supersedes the outgoing version in one transaction, so there is never a moment with two policies in force or none. Changing the rule that governs money should be a deliberate act with a moment to read it back, not a form that takes effect as it is typed.
098a179653 - Team: invite somebody, so live mode works for more than one person
C-1, ported from the Cartulary investigation's inviteMember. Live mode was reachable by exactly one person, the founder, through a hand-seeded invitation, and several controls already assume two: a second approver above the policy threshold cannot be a different person while there is only one. The screen says so when it is true.
2271e2fabe - Port the control plane's write side from Cartulary: decide, register, suspend, issue
The buttons on Approvals did nothing. An approvals queue whose controls are decorative is the worst state for that screen: it looks like a control surface and is a picture of one. Cartulary had resolveHold working; this is that, adapted.
357258f4a3 - Test mode sends payments and creates mandates, because that is what test mode is for
Send and Mandates were inert in both environments. I applied "do not overclaim what live can do" to test mode, where it does not belong: test mode exists to demonstrate the product, and a demonstration that explains itself instead of running is not one.
a701fb57c6 - Seeding is one command, and a stale note claimed a protocol bug that is fixed
npm run seed applies the fixtures, generates the rows whose hashes must come from the real canonicaliser, seals the receipt chain, verifies it, and checks the invariants. It used to be three commands in an order you had to know, and missing the last one left a chain that did not verify while every screen still rendered, the failure that made verify-chain necessary.
4d64d3549f - The vendored protocol has to carry its own dependencies
The deploy failed on @noble/ciphers, @noble/curves and poseidon-lite. Locally they resolve through the workspace root by hoisting, which hid the fact that the vendored package never declared them; the deployed app installs only its own directory and had nothing to resolve.
a1b67dd9b0 - Deploy the app with the protocol it actually runs
The browser balance made web depend on the protocol package, and that broke deployment: Vercel uploads only the Next project directory, so npm install failed on a workspace package that was never uploaded. Deploying from the repo root builds fine and then fails on upload, on both preview and production, with a Vercel toolbar error that suggests a Next upgrade we are already ahead of.
b41e118164 - The private balance is computed in the browser, from the real pool
The figure the app has been explaining it could not show. 425.50 across 4 unspent notes, read from a devnet pool by trial-decrypting every envelope in it against a key derived in the tab and never sent anywhere.
b996e5d85d - Write-once wallet binding over the API
GET and POST /api/v1/agents/wallet.
8a205fc53d - One component for "not built yet", and every one of them leads somewhere
There were eight of these written as bespoke prose, Send, Mandates, Keys, Developer, Connect and three payee screens, each explaining the same architectural fact at a different length and in a different shape. Each was accurate. Together they were a product that spends its screens apologising.
0fd8c882de - Nothing in the app could add a payee, so a new organisation had no first move
Send offered "set up a mandate". The mandate form said there were no payees to write one for. That was the loop, and it was the whole product for any organisation that had not been seeded by hand. Overview's "send the first one" fed into the same circle, Agents pointed at a Developer screen with no action on it, and Payroll offered an Import CSV button wired to nothing.
ac8a5f74b0
2026-08-22
- Every screen was 500ing because my own edit dropped connectionString
The outage was mine and the diagnosis took three wrong turns worth recording.
c0070a0924 - Every screen failed at once, from load rather than from a bug
Agents rendered the error boundary in production. The cause was not the screen: "max clients reached in session mode - max clients are limited to pool_size: 15". Every database-backed screen was failing, from connection exhaustion, and it looked exactly like a broken page.
87438f50fb - Agents was cramped, and colour was being spent on things that are not verdicts
Looking at the built screen rather than the code. Four defects.
b3e19c7b81 - The badges were never styled, and the disabled controls looked enabled
Two UI defects I introduced while fixing the data.
c4970e57c6 - The audit was wrong: every sweep I ran was signed out
I reported the app clean of invented figures. It was not, and the reason is the method. Every sweep ran in live mode while signed out, which hits the sign-in gate, so I was measuring the gate, not the screens behind it. Signed in, /mandates/new rendered a payee, a purpose, an amount, two hard caps, an expiry, an approval rule and a bindingHash of 0x9c4e…12ab for a mandate that did not exist.
cb9191a1fa - Invariant checks, and the escalated payment they found in limbo
scripts/check-invariants.mjs asserts 21 claims the product makes, against the database. They exist because a screen and the data behind it are written by different code paths and nothing asserts the rule between them, in the Cartulary investigation this was found the hard way, by a mandate whose own utilisation exceeded its daily limit, on the screen whose purpose is demonstrating that limits are enforced.
658bea7823 - The control plane as an API
D-24, the agentic surface. POST /api/v1/decisions and GET /api/v1/payments/:id.
f45e5309d6 - The approvals list becomes a hold desk
D-21. Two facts the schema and the policy have carried from the start, rendered nowhere: a queue breaching its own service level looked exactly like one that was not.
c369a6fe7f - Disclosure and Keys wired; no screen holds sample data any more
Disclosure needed a schema change to be honest. guest_links carried a token hash and an expiry, enough to make a link work, not enough to describe one, and a disclosure the payer cannot describe is one they cannot meaningfully revoke. It now carries env, holder, scope_label, uses and last_used_at, and the screen answers the question it exists for: should this key still exist? Seeded across the four states it must render distinctly, active and heavily used, issued and never opened, expired, revoked early, plus an access log that includes a refusal, because a key that was declined is the entry proving the log records rather than advertises.
01f729f29c - The payee surface stops inventing payments
Every constant is gone from /pay. lib/payee.ts is deleted, and with it the PAYER name that five screens used to state who had paid you.
21d52c29c0 - Send stops printing a balance no server could know
The screen you caught first. Everything on it that was not a form control was invented: 2,942,940.22 free to send, four privacy-score factors, two suggestions built on those factors, a prefilled 250,000.00, a 1.40 fee, and "4 notes, oldest deposited 31 days ago".
822ba6192a - Agents and Policies: two of the incorporated surfaces, built
D-19 and D-20. Both tables were already here and referenced by no screen.
2cb9f3bf97 - Put Cartulary's whole feature set on the roadmap, not just its utility modules
I read "incorporate the features" as "port files" and started with the smallest one. The actual gap is larger and more specific: Recurro already carries Cartulary's data model, because it took the migrations, agents, api_keys, policies, holds with sla_deadline and second_approver, guest_links, access_requests, instruments, rail_submissions. Six of those tables are referenced by zero screens here. The model arrived; the product did not.
d8b34407b8 - Port Cartulary's canonical JSON so the receipt chain means something
First item from D-18. The chain hashed labels: sha256("mnd-0043:aug-2026"). Links were internally consistent so it looked like a chain and rendered as one, but the amount, the payee and the payload could all change without moving the hash. Every one of the 89 seeded receipts was in that state, and the receipt screen said "Signature verified offline" over the top of it.
8351b66d5e - Audit the Cartulary investigation for what Recurro should reuse
Rather than rebuilding what already exists. Seven areas, ordered by what they fix.
ab73ceb37d - Remove the gates from the roadmap data, not just the section
Deleting the section left the items pointing at it: "All four gates below hold" and two "(a gate, below)" references now named a section that was not on the page. Each item says what it needs in its own terms, and the gates data is deleted rather than left exported for something to render again. ROADMAP.md keeps them, which is where release governance belongs.
076e7f4b20 - Live mode resolved to no organisation for everyone, so wiring a screen could not have helped
orgId(env) returned null unconditionally for live. Every wired screen therefore returned its empty shape to every signed-in operator looking at their own organisation, the same nothing a stranger saw. Overview was not the problem; this was, and it sat under all six screens. Live now resolves through currentMember(), which is request-cached, so it costs one query however many screens ask.
d1e020caa6 - Audit: a receipt that claimed a verified signature it never checked
Full sweep of every route in live mode for invented figures. One serious finding beyond the payee gate.
495548a28c - Test mode is a choice, not the state a stranger arrives in
pay.recurro.xyz was publicly serving an invented transfer: 12,800.00 USDC to 0x4c2a...9b71, a privacy score of 45/100, "linked to roughly 9 accounts", block 24,881,032. None of it exists. Anyone who opened the payee link saw figures about their own money that were made up.
f4ba61cc7d - Catch a failed screen without showing the reader a stack trace
/approvals still returned a 500 after the shell was fixed: the shell degrades, but each wired screen queries on its own. An error boundary in the (app) group covers every screen that exists and every one added later, rather than asking each to remember.
87c818a8c0 - An unreachable database is a state, not a stack trace
app.recurro.xyz/overview returned a 500 with the SUPABASE_DB_URL error text. Two unguarded reads in the payer shell: getApprovalsWaitingCount, which runs in both environments and so broke test mode as well, and currentMember. flags() and pool.ts already treat an unreachable dependency as an honest state; the shell did not.
4bc6ecfb57 - Do not strand a signed-in person when the invite claim fails
The auth callback claimed pending invites without a guard. By that point exchangeCodeForSession has already established the session, so a database blip turned a successful sign-in into a 500 and an error page, for a step that currentMember() already retries on the next request. Catch and fall through to /overview.
148e16e6bb - Three hosts, one codebase: marketing, payer and payee on separate origins
The payer and payee surfaces hold spend keys and note plaintext in browser memory. On a single origin, any script running on the marketing page, an analytics tag, a widget, a compromised dependency, shares cookies, storage and script context with those keys. A separate origin is the only mechanism the browser offers that actually stops that, and it lets the two kinds of surface run genuinely different policies; one origin could only ever have the looser of the two.
1eb2ff24eb - Administrators move to the database; site live but not indexable
Administrators are now the super_admins table rather than an environment variable, as asked. That moves a privilege inside the product, so the surface is kept small: RLS on with no policies so only the server-side pool can see it, writes through one action that checks the caller is already an administrator, every grant and revocation written to audit_log, you cannot remove yourself, and the last administrator cannot be removed. Emptying it is recoverable only by SQL, which is documented.
bdfa23103e - First live organisation, invite-based onboarding, admin allowlist made visible
prasant@gmail.com is invited to the Recurro organisation as operator. A member row cannot be pre-created, members.user_id is not null and there is no auth user until someone signs in, so the invitation is the handshake: sign in with that address and onboarding.ts binds the membership and consumes the invite. Matching uses the email getUser() verified, never anything the client sent, and each invite is consumed once. Being invited after a first sign-in also works, because currentMember() retries the claim when it finds no membership.
311753a789 - Public roadmap; demote the one shipped claim that was not true
Roadmap borrows the Cartulary investigation's rules: no dates, nothing marked shipped before it is live, scope cuts recorded rather than deleted, and everything unbuilt states what must be true before it ships, a roadmap that lists titles says what we want, one that lists preconditions says whether we understand the problem. The four gates lead the page, stated plainly as not yet holding.
b457eca63c - Feature flags, and gate the solvency page on the anonymity set, not just on a switch
You were right that a solvency page before critical mass is premature, but the sharper problem is that it is a privacy leak: publishing 'N notes in the pool' when N is small tells an observer exactly how weak the anonymity set is. So /proof is gated on two conditions, someone chose to publish it, AND the set is above a threshold, and the flag can only ever make that stricter. A switch that can turn a safety property off is not a safety property. Below the threshold the route 404s rather than 403s, and the landing page stops linking to it.
8d9aef6afa - R1: Mandates, Payroll review, Receipts and Developer read from Postgres
Four more screens converted to the Approvals pattern: async server component awaits searchParams and currentEnv(), calls an env-scoped query, renders a presentational component. Seed extended so test mode shows the same values as the mockups; all five wired routes build as server routes.
0d7a793b63 - Public proof page shows only figures it read; wordmark links home everywhere
/proof carried invented reserve figures (a .9bn 'Every note is backed, right now'), claimed to update every block while being static, and named Trail of Bits and Zellic as auditors, none of which was true. A solvency page that invents figures is worse than no solvency page, so it now reads the pool indexer and, when there is no pool, says exactly that. The audits section says 'None yet'.
0f8c487da1 - Auth: magic-link sign-in gates live mode; test mode stays open
Borrowed from the Cartulary investigation, including its reasoning: getUser() verifies with the auth server rather than trusting a cookie, React cache() scopes that to one verification per request, and authUnavailable keeps 'the service refused to answer' distinct from 'you are signed out', telling a signed-in operator they are signed out because we asked too often is its own kind of lie.
ccc011f7ec - Live mode stops showing sample data; explicit two-state environment switch
An unwired screen rendering 'Free to send 2,942,940.22 USDC' in live mode is a false statement about money, not a placeholder. Middleware puts the path on the request, the app layout checks lib/wired.ts, and anything not yet reading real data renders an explicit notice in live mode instead of its constants. One place, so a new screen cannot forget it; the list only shrinks.
2a359ed7fd - R1: Approvals reads from Postgres, seeded test mode, empty live mode
The reference implementation for D-15. Approvals is now a server component: awaits searchParams and currentEnv(), queries Postgres scoped by env, renders a presentational component. Test mode shows the three seeded approvals with stats derived in SQL; live mode shows the real empty state for the first time.
87c701466d
2026-08-21
- ROADMAP.md: sequenced R1-R5 plus the four gates; backlog build plan now points at it
Scope moved substantially (Next/Supabase, the Cartulary harvest, agentic payments, ecommerce), so the old phase list was stale. One sequence, in one place: R1 the app is real, R2 payouts work, R3 agentic payments, R4 ecommerce, R5 connectivity, with privacy, protocol safety, legal route and customer commitment as gates running across all of them rather than as phases.
36e9a8613e - Next.js migration: 22 routes, test-mode toggle, landing page
- web/: App Router. Marketing (/), payer app ((app)/*), payee surface (/pay/*), public /proof, /api/env. /proof and the payee pages are server components, the SSR the migration was for. - test mode: cookie read server-side, unmissable band, toggle in the topbar, POST /api/env. Screens still hold sample data; swapping that for env-scoped queries is the rest of D-15. - landing page on the product tokens: hero, the observer panel (public transfer vs the same payment through the pool), control/evidence/disclosure/recovery, how it works, engineer specs, honest status. - consolidated the duplicate Logo; copy-check now scans web/ as well as the old app/. - 22 tests pass, copy-check clean, production build green.
c57a45a479 - workspaces + client/server boundary guard; test-mode and landing-page plans
- npm workspaces (protocol, sdk, node, web); one hoisted node_modules instead of five - protocol/src/browser-only.ts: keys and note plaintext throw if loaded off-browser, so the client/server boundary is structural under Next rather than a convention. 2 tests. - docs/D-15-test-mode.md: demo data is data, not a code path, seeded simulated env vs empty live, toggled like Stripe, isolated by the env column already on 8 operational tables - docs/D-16-landing-page.md: structure and copy, with the withdrawn claims kept withdrawn - backlog v3.6: D-15, D-16, I-1
895f68435f - db: Supabase Postgres, control-plane schema ported from the Cartulary investigation
18 migrations applied to the Recurro project: orgs, members, agents, policies, mandates, counterparties, payments, holds, receipts, audit_log, api_keys, instruments, rail_submissions and friends. All 16 tables have RLS enabled; tenant isolation via member_orgs(), append-only via forbid_mutation(). .env.example committed, .env.local gitignored.
7ad7473d3c
2026-08-16
- RUNNING.md + devnet scripts (up/down/deploy) + observer demob4b58d5db7
- Backlog v3.5 / MVP: real-proof e2e recorded834a924704
- sdk + e2e: deposit → private send → scan → withdraw on anvil with REAL UltraHonk proofs and on-chain HonkVerifier
- sdk/: wallet, tx builders, MockProver/NargoBbProver, client, indexer client, 7 tests - circuit fee semantics aligned with contract (fee outside publicAmount); public-input order aligned to circuit ABI - Deploy.s.sol deploys HonkVerifier under REAL_VERIFIER=true; e2e passes with and without REAL_PROOFS=1 - 24 Foundry tests, 8 circuit tests, 12 protocol tests, 7 sdk tests
6274912fc0 - Real UltraHonk proof (0.49s, 9,152 bytes) verified with bb and on-chain via generated HonkVerifier (3 Foundry tests, 3.84M gas unoptimised)dd8007fa83
- Backlog/MVP: reflect built protocol, circuits, contracts, nodeae179ffa7d
- circuits: transact (2-in/2-out, Merkle, Grumpkin ownership, balance, nullifiers, bindingHash), 8 tests, TS-generated witness executes; contracts: Pool + MerkleTreeWithHistory + hashers + mocks, 21 Foundry tests
Poseidon in Noir (noir-lang/poseidon v0.3.0) verified byte-identical to poseidon-lite; commitment, nullifier, owner tag and 32-level root computed in TS reproduce in the circuit.
a4d654a244 - node: indexer (tree + envelopes from pool events) + relayer (binding checks, submit) + HTTP API40fb5e6156
- protocol: note/key/stealth/merkle/envelope reference implementation, 12 tests passing0d3ed8cd18
- MVP.md: the one page in front of everything; charset + plain-text status in backlog outputs6455c99df7
- Backlog v3.3: connectivity/notification/acceptance as costed hypotheses, not pilot items
- C-14/C-10 reduced to the pilot minimum (browser wallets + web claim flow; payer webhooks + note discovery) - A-1 and G-2 are compatibility checks, not designs; X-1 acceptance hypothesis; new X-2 build hypothesis - decision #5: connectivity layer, build or adopt, both branches costed; neither assumed - positioning: private recurring payouts with organisational control and auditability; cost/reach are hypotheses; withdrawn the unsupported fee and 'no rail offers private acceptance' claims - P0-12 §4.8b tests the angles separately (switch vs reassure, baseline, minimum saving, non-negotiables)
cbe40a3797 - Build the connection, delivery and acceptance layers ourselves; backlog v3.2
- C-14 wallet connection (injected providers + deep links; Recurro Connect over our relay in P2) - C-10 encrypted delivery/notification relay addressed to viewing keys (no PII) - A-1 payment requests into the private pool (native address-per-tx via stealth outputs; private settlement) - X-1 private acceptance network (refunds, disputes, fiat leg, gated by licence + PSP partner) - G-2 protocol spike: design primitives for payouts AND acceptance before P0-2 hardens - docs/positioning-and-acceptance.md replaces the earlier integration framing
fddaa73645 - Positioning beyond privacy; WalletConnect as reach/spend layer
- docs/positioning-walletconnect.md: four angles (cost, reach, control, privacy); what WalletConnect is for us - backlog v3.1: C-14 wallet connect/auth via WalletConnect; C-10 names Notify as no-PII push candidate; A-1 'Spend it' via WalletConnect Pay; X-1 private payout destination inside WC Pay (PSP partner only) - P0-12: positioning probe, respondents rank the four angles
3337ac82d5 - Backlog v3.0: reorganised around one pilot, five categories, gates, pilot scope
- categories G/C/A/X/I; scores compared within a category only; confidence capped 0.6 on untested features - gates: protocol safety, legal route, practical privacy (G-1 threat model, P2-4 entry/exit, P1-12 anonymity set), customer commitment - pilot scope: one chain, one stablecoin, org payer, existing-wallet payees; 13 operational-control items C-1..C-13 - P1-1 narrowed; P1-4 follows P0-11; P2-7 governed recovery pilot-critical; P2-2 split (P2-2b portal/integrations); D-2 demoted to experimental presentation layer; P1-13 = one committed design partner - build plan: launch model, decision #0 (app vs infrastructure), four gates, M0–M3
31c38bdec0 - P0-12 guide: remove process reference5e2dd8b2e9
- P0-12: interview guide rewritten wedge-first; M0 gate threshold set; backlog v2.2
- primary question is switching; recent-event reconstruction; alternatives / decision process / privacy demand / switching evidence blocks; Kano as rotating secondary module, pooled + directional - concept cards unbundled (P0-5, P1-8, P2-2, P3-2); P1-4 as three explicit compliance models; D-2/P2-5 tested as one capability; unit-first pricing (van Westendorp) in a 10-min follow-up - findings carry Kano / Importance / Adoption effect separately; re-score is a separate step with a rationale per changed number - backlog: P0-12 retitled; M0 gate = ≥5 of 15 across ≥2 segments name cohort + volume + product/legal blockers
5e8be6144c - D-9: auditor portal spec (brief for Claude Design turn 3); backlog v2.10b06b66a40
- D-10 done: WCAG 2.1 AA, token-level contrast fix, underlined links, skip link + landmarks
- ink-2/ink-3/ink-4 re-picked to pass 4.5:1 on every surface (was 2.6:1 on all labels/meta) - links underlined by default; buttons/nav/.link opt out - skip link + <main> in both shells; scripts/a11y-check.mjs; docs/D-10-accessibility.md - backlog v2.0
309ba28254 - D-7 done: states on every screen; P0-11 licensing memo draft
- components/States.tsx primitives (loading hairline, skeleton, empty sentence, error reason-first, held/refused, stale) - ?state=loading|empty|error|held|stale on 15 screens; Proof not-backed state; PayMove Poor two-step confirm (D-2) - docs/D-7-states.md, docs/P0-11-licensing-memo.md; backlog v1.9
3c976de8a5 - D-4 done: sentence templates in code + vocabulary check
- app/src/copy/sentences.tsx: mandate, limits, receipt (payer/payee), paidYou, scheduled, disclosure, hold - six screens now render from templates (byte-identical output) - scripts/copy-check.mjs guards the D-4 word list; fixed Overview 'Shielded balance'/'withdraw-only', Receipt 'On-chain' - backlog v1.8
7e50969ba8 - D-6 design review: designer additions scored against the backlog
- docs/D-6: table of every addition beyond the spec, adopt / gate / rewrite, with scores - app/src/features.ts: flags named for the backlog items a drawn control depends on (P1-11 Solana, P2-4 batched exits, P3-4 published schema, D-13, D-14); gated in code - backlog v1.7: D-13 payee payment history, D-14 payer-provided contact
f212611d8a - D-6: payee-side flows, eight screens from Claude Design turn 2, implemented under /pay/*
PayShell (payer as context, no sidebar), Notified/Scheduled, Connect (pick/scanning/not-found), Move with the privacy score, Moved, Receipt (payee view), Share with accountant, What private means. Mobile-first at 390, desktop two-column where the mockup has it. Backlog v1.6: D-6 done.
ea14a120ed - P0-1/P0-5/P0-10: protocol spike design doc
Note model with appData + bindingHash, key hierarchy with disclosure/recovery slots, transact circuit sketch, contract touchpoints, benchmark plan with exit criteria E1–E10.
3a57621229 - Backlog v1.5: status tracking + design track docs
- recurro-backlog: todo/doing/done status with evidence path per item; status filter; progress line - scripts/backlog-md.mjs regenerates the .md from the .html data - docs/: D-1 principles, D-2 score spec, D-4 copy system, D-6 payee flows, P0-12 interview guide - app: all colours tokenised (D-1 acceptance), template assets removed
d55dab0448 - Recurro: scored backlog, build plan, and web app on the design token system
- recurro-backlog.md/.html: 56-item RICE × Kano backlog (P0–P4 + design track), build plan, assumptions - app/: Vite + React + TS; tokens, shell, score component, ten screens from the Claude Design mockups - design-source/: mockup markup per screen
4900878679