# Dylan Pulver > Software engineer and entrepreneur in Toronto. Builds complete platforms end > to end, from the database schema to the app store listing: payments > infrastructure, a three-sided marketplace, live-operations software. ## Who he is Dylan Pulver is a Canadian-American software engineer and entrepreneur based in Toronto. He grew up in Chicago, studied Applied Mathematics at Johns Hopkins University and holds a Master of Mathematical Finance from the University of Toronto. His first engineering job was at Coinsquare, at the time the largest cryptocurrency exchange in Canada; the decade since has been fintech and automation platforms built end to end: schema, money-movement functions, security policies, mobile and web apps, admin consoles, email, cron, CI, monitoring, store submission and marketing site. He is a co-founder of GoldVault, a B2B payments platform, and advises founders and engineering teams on product engineering and payments. He also writes and records music; the musician and the engineer of the same name are the same person (Spotify: https://open.spotify.com/artist/0EdZQUgxIXfaQhbKN2xSJu). ## Three platforms ### GoldVault Payment infrastructure for high-risk merchants. Identity, risk screening, a ledger and fee collection, sitting between a merchant's customers and the merchant's own card processing. It never holds anyone's money. Stack: Next.js, Postgres with row-level security, Supabase, Vercel, Sentry, Metabase. Status: In production since 2025. Co-founder. I built the engineering and I run it.. Full anatomy, every stage with its decision and cost: https://dylanpulver.com/platforms/goldvault - 914 commits, Jul 2025 to Aug 2026 - 95k active lines, TypeScript and SQL, archive excluded - 100 Postgres functions, shipped as migrations - 2 processors behind one adapter, a third slot reserved - 4 auth mechanisms across three trust tiers - 21 admin screens ### Glitz A three-sided commerce marketplace. Brands list products, creators mint tracking links, buyers purchase through them. Commissions are attributed automatically, held thirty days, and paid out, with teams of creators splitting the commission by rule. Stack: Flutter, Firebase, Cloud Functions, Next.js, Stripe Connect, Shopify. Status: Live on iOS, Android and web. Built for a client; live payments and live Shopify confirmed May 2026.. Full anatomy, every stage with its decision and cost: https://dylanpulver.com/platforms/glitz - 787 commits across mobile, backend and web - 83k lines of Dart, 94 screens - 59 Cloud Functions - 89 web pages against the same database - 58 mobile builds shipped - 34 Maestro flows, screenshot helpers included ### MicroGym A live-operations tool for small-group training, built with a Toronto gym. Trainers assemble workouts, schedule sessions with a roster, then run the session live on a tablet while an assessment engine adapts every exercise to every member in the room. Stack: Next.js, Supabase, Postgres with row-level security. Status: Built March 2025 to July 2026 for one gym. No tests and no CI, said plainly.. Full anatomy, every stage with its decision and cost: https://dylanpulver.com/platforms/microgym - 502 commits, all one author - 81k lines across 344 files - 34 tables - 1,507 lines in the substitution engine - 4 phases of security hardening, each reversible - 4 to 0 rows a member could see that were not theirs ## Everything shipped - 2025, GoldVault: Payment infrastructure for high-risk merchants. Co-founder; built and run the engineering. (Next.js, Postgres, Supabase, Vercel; In production) - 2025, Glitz: Three-sided commerce marketplace on iOS, Android and web, with team commission splits. Built for a client. (Flutter, Firebase, Next.js, Stripe Connect, Shopify; Live) - 2025, MicroGym: Live-operations tool for small-group training, with per-member exercise substitution. (Next.js, Supabase; In use at one gym) - 2026, sealbinhan.com: Personal site for an investor and operator: engraved illustration system, lead pipeline, both themes. (Next.js, Resend, Vercel; Live) - 2026, Athens Eye Care: Site rebuild for an ophthalmology clinic in Athens. (Next.js, Vercel; Live, awaiting domain cutover) - 2026, Sealed Hunt: Research platform for collectible grails across 81 categories, the research as well as the build. (Single-file web app, Vercel; Live) - 2026, crm-master: Open-source CRM with a nudge queue for people you owe a reply. (Next.js, Supabase; Open source) - 2026, dylanpulver.com: This site. The anatomy component, the signal drawings and the lead pipeline are hand-built. (Next.js, Resend, Vercel; You are here) ## Open source 191 pull requests merged into 160 open-source projects between 2026-08-18 and 2026-09-27, including Homebrew/brew, react-hook-form/react-hook-form, ManimCommunity/manim, pnpm/pnpm, raysan5/raylib, jdx/mise, python-poetry/poetry, tokio-rs/tokio, alpinejs/alpine, symfony/symfony, nrwl/nx, keepassxreboot/keepassxc, postcss/postcss, celery/celery, Automattic/mongoose, rollup/rollup, stretchr/testify, fmtlib/fmt, samber/lo, bokeh/bokeh. Full list, each linked to the merged change: https://dylanpulver.com/open-source ## Receipts Every number and decision on the site with the command or file and line that produced it, and what was corrected when the repositories were re-read: https://dylanpulver.com/receipts ## Full text Every anatomy stage and every note in one plain-text file: https://dylanpulver.com/llms-full.txt ## Engineering notes Four decisions from the platforms above, written out at full length: what went wrong, what it cost, and what the fix had to survive. - [The webhook that credits twice](https://dylanpulver.com/notes/idempotent-payment-webhooks): A payment processor delivers every result at least once. The interesting engineering is in what happens when your side fails halfway through. - [A ledger you can edit from a route handler is not a ledger](https://dylanpulver.com/notes/ledger-in-postgres-functions): Why every movement of value on the payments platform goes through a Postgres function, and what it costs to keep it that way. - [Four rows that were not theirs](https://dylanpulver.com/notes/row-level-security-hardening): Hardening multi-tenant row-level security in four reversible phases, verified by impersonating a member and counting what they could see. - [The bug inside the fix](https://dylanpulver.com/notes/one-pair-of-hands): A payout race, the fix for it, the bug inside that fix, and why it is the best argument I have for one person owning the whole path. - [The sale that arrives with no referrer](https://dylanpulver.com/notes/attribution-through-an-in-app-browser): A creator shares a link on Instagram, the buyer taps it, installs the app and purchases. Nothing in that chain carries the creator's name across. Here is what does. - [The minimum that belongs to someone else](https://dylanpulver.com/notes/payout-minimums-per-brand): Held commissions are released to creators once a brand's payout minimum is met. Group the money by creator, the obvious way, and one brand's minimum ends up holding another brand's money. - [The commission that must not mint twice](https://dylanpulver.com/notes/exactly-once-commission-on-a-document-store): A buyer pays and the platform owes a creator a commission. Between those two facts sit a colluding pair, a call that arrives twice, and a cart with three items on one payment. - [The rule that has to be written twice](https://dylanpulver.com/notes/security-rules-do-not-cascade): Firestore security rules do not cascade to subcollections. Forget that in one place and a single query returns every private message on the platform. - [Thirty days in the ledger](https://dylanpulver.com/notes/thirty-days-in-the-ledger): Splitting a payment at charge time is simpler and wrong: a refund after the creator is paid is a clawback nobody enjoys. Holding the money creates a different set of problems, and each piece of machinery around the hold answers one of them. - [Arbitrary but consistent](https://dylanpulver.com/notes/arbitrary-but-consistent): A gym's assessment answers become rules that swap an exercise for a member before a session. Two rules can disagree about the same movement. The code says who wins, and the comment admits how. - [The number that ducked every threshold](https://dylanpulver.com/notes/risk-checks-on-the-charged-amount): A deposit arrives from a browser carrying two numbers. The platform scored one of them and charged the other, and every limit it had was measuring the wrong figure. - [The second one tells you the shape](https://dylanpulver.com/notes/extracting-a-payment-processor-adapter): High-risk merchants get dropped by processors, so the platform cannot be welded to one. The interface that makes a processor swappable could not be designed before the second one existed. ## Questions people ask ### What do you actually do? I build software platforms end to end and stay accountable for the whole thing: the database schema, the money movement, the security policies, the apps, the admin console, the email, the deploys, the store listing. Three of them are in production now. ### Do you work alone? For most builds, yes. The bugs that cost the most live at the handoffs between people, and a build with no handoffs does not have them. When a build needs more than one person I bring them, and I stay accountable for the whole. ### Can you take over a codebase you did not write? Between 18 August and 27 September 2026 I read 160 open-source codebases I had never opened and had a fix merged into each one: 191 pull requests in 41 days, in tools from Homebrew to Celery. Reading someone else's platform and finding what is wrong with it is most of the job. The list is on the open-source page, each change linked. ### Who do you work with? Founders with a platform to build, and companies with a live system that needs someone to own the whole of it. I grew up in Chicago and live in Toronto, a dual Canadian and US citizen, so working with a US or Canadian company is straightforward, and most of it happens remotely. ### Are you a fractional CTO, a contractor, or what? Call it what your org chart needs: founding engineer, fractional CTO, the one contractor who owns the platform. The shape is the same. I take the whole path, from the schema to the store listing, and I stay accountable for it after launch. Fixed scope or ongoing, whichever the build needs. ### Who is this not for? Agencies looking for a subcontractor. Teams that need one more person on a ticket queue. Anyone who wants a slice: the backend without the app, or the app without the backend. I take the whole thing, or I point you to someone good for the part you need. ### Are you taking on work? Yes, one or two builds at a time. Either a build gets the attention it needs or I say so before it starts. The fastest way to find out is to send a note below. ### What do the first two weeks look like? Day one, repository and infrastructure access in your company's name. Week one, I read the whole codebase the way I read the 160 on the open-source page, and you get a written list of what I found, ordered by what it would cost you. Week two, the first change ships, with its trade-off written next to the code and a migration you can roll back. If the list says the build should not go ahead, you hear that in week one too. ### What happens if you get hit by a bus? The next engineer opens the repository and finds the migrations in order, the trade-offs written next to the code, tests where money moves, and a README that runs it. That is not a handover promise; it is how the platforms are built, because I am also the person who has to read them a year later. For anything with a company behind it, the repository and the infrastructure are in the company's name from the first day. ### Are you the Dylan Pulver on SoundCloud? Yes. Same person. I have written and recorded music since before I wrote software, and the studio is where the drawings on this page come from. Engineering decides whether a thing works; taste decides whether anyone wants it. ### What is the fastest way to reach you? The form below or dylan@diamondhook.com. Both come straight to me, nobody screens them, and I reply within two business days. ## Ways to work with him - A platform to build: End-to-end build of a new software platform by one accountable engineer: data model, payments, security, web and mobile apps, admin tools, deployment and launch. - A build to lead or rescue: Taking ownership of a live system or a stalled build: understanding the whole of it, fixing what is wrong, and carrying it forward. - Advice on architecture or payments: Architecture and payments-infrastructure advice for founders and engineering teams, grounded in systems that are running in production. ## Working with him The usual shape is a founder with a platform to build, or a live system that needs someone to own the whole of it. A note goes directly to him; a half-hour conversation follows; if he is the right person he says how he would build it and what it would take, and if he is not, he says who might be. For larger builds he brings a team through Diamond Hook. ## Links - [Home](https://dylanpulver.com) - [LinkedIn](https://www.linkedin.com/in/dylanryanpulver/) - [GitHub](https://github.com/dylanpulver) - [SoundCloud](https://soundcloud.com/dylan-pulver) ## Contact Email: dylan@diamondhook.com # The anatomies, in full ## GoldVault Payment infrastructure for high-risk merchants. Identity, risk screening, a ledger and fee collection, sitting between a merchant's customers and the merchant's own card processing. It never holds anyone's money. Stack: Next.js, Postgres with row-level security, Supabase, Vercel, Sentry, Metabase. Status: In production since 2025. Co-founder. I built the engineering and I run it.. Page: https://dylanpulver.com/platforms/goldvault ### 01 Checkout: The merchant's customer starts a deposit or a cash-out. The problem: The client declares the deposit amount. Risk and velocity thresholds are evaluated on that number, and the card is charged in full. A small declared amount would duck every threshold. The decision: Risk and velocity evaluate the figure that becomes the card charge, not the declared split beside it. Both arrive from the browser; only one of them is what the card is charged. The declared amount is treated as untrusted input like everything else that arrives from a browser. What it cost: Nothing moved. One variable changed and the ordering did not: the check still runs before the processor is called, on the amount the processor will be asked to charge. The cost was the version that shipped first, where a small declared amount ducked every threshold, and the commit that fixed it says so. ### 02 Identity: Document verification, then an SMS check before money moves. The problem: The SMS verification id was an optional parameter. Anyone calling the API directly, rather than through the app, could leave it out and skip the check and its dispute-evidence trail entirely. The decision: Made it required, with the reason written next to the change so the next person does not relax it for a convenience case. What it cost: Making a field required is a breaking change for every caller that had been omitting it, which was the point. One legacy deposit route still declares it optional and never reads it; that route has no callers in the app and is on the list to delete. That is what the parameter being optional had been hiding. ### 03 Risk: Velocity limits and a shared-device graph across customers. The problem: Device fingerprints arrive from the client. A malformed or forged one, if stored as-is, links unrelated customers together in the shared-device graph and poisons every decision downstream. The decision: A fingerprint that fails validation is recorded as absent, not as whatever string was sent. On the live deposit path the graph only receives values that passed that check. One legacy route still writes the raw value, which is why that route is on the list to delete. What it cost: A customer on a browser that mangles the fingerprint gets slightly less protection, because absent is weaker evidence than a valid match. That is the right direction to fail in. ### 04 Processor: The charge goes to one of several card processors. The problem: Merchants in this category get dropped by processors. If the platform is welded to one, a processor leaving is an outage that lasts as long as a rewrite. The decision: One adapter interface, per-tenant credentials, weighted routing. Two processors sit behind it today, with a third slot already carved out in the types and the schema. One has already left, and the code for it moved to an archive folder rather than into a crisis. What it cost: Every processor has its own idea of a webhook, a signature and a refund. The adapter is the place all of that gets normalised, and it is among the code that changes least: the interface has one commit and the busiest adapter eight, against eighty for the busiest dashboard screen. ### 05 Webhook: The processor reports the result. It retries until acknowledged. The problem: Delivery is at-least-once. The body is unverified until you have looked up the right tenant's secret, and the only link back to a customer is a session id inside that unverified body. One processor's documented signature scheme was also wrong. The decision: Deterministic operation ids, derived from the delivery itself, so a retry collides with its own first attempt instead of crediting twice. A dedup slot claimed on arrival. And the part most implementations miss: when the credit fails, the slot is released and a 503 goes back, so the processor redelivers instead of the credit being lost. The signature scheme was reverse-engineered from a captured sandbox delivery. An unknown tenant and a bad signature return the identical response, because different responses would let someone enumerate tenants. What it cost: A hand-rolled UUIDv5 rather than a dependency, and a loose header scan that had matched the hosting platform's own proxy signature header, since pinned to the exact name with the reason written beside it. Both are documented in the code so they do not get cleaned up by someone helpful. ### 06 Ledger: Every movement of value, in Postgres, through functions only. The problem: Application code with a database connection can write anything. A ledger that can be edited from a route handler is not a ledger. The decision: Deny-direct-write policies on the money tables. All movement goes through Postgres functions, and the row-level security policies and one legacy ledger function are tested with pgTAP; the live payment-rail functions are not yet, and that is the next test to write. What it cost: Around a hundred functions, and a migration for every change to one. Slower to change, which for a ledger is the point. ### 07 Payout: A customer cashes out. The processor is asked to send the money. The problem: Two requests for the same payout arriving together could both reach the processor. Double-pay, real money. The decision: Flip the redemption to an initiated state atomically before calling the processor, and compensate on every failure branch. Only the request that won the flip proceeds. What it cost: The fix had a bug inside it. The database layer re-applied the filter on the returned row, so the winning update came back empty and every payout looked like a conflict. Finding that meant reading the query layer's source rather than its docs. It is the kind of story that only exists if one person owns the whole path. ### 08 Compliance: A certificate of controls for sponsoring banks and underwriters. The problem: Banks and underwriters ask what controls exist. A slide deck is not evidence. The decision: A generated PDF certificate of compliance controls with an evidence-exhibit pipeline behind it, with a tests-of-controls matrix and an exhibits section; the pointer from a control to its exhibit is editorial today, not structural. What it cost: It is a document generator inside a payments platform, which nobody plans for. It exists because the people asking were the ones deciding whether the platform could operate at all. ## Glitz A three-sided commerce marketplace. Brands list products, creators mint tracking links, buyers purchase through them. Commissions are attributed automatically, held thirty days, and paid out, with teams of creators splitting the commission by rule. Stack: Flutter, Firebase, Cloud Functions, Next.js, Stripe Connect, Shopify. Status: Live on iOS, Android and web. Built for a client; live payments and live Shopify confirmed May 2026.. Page: https://dylanpulver.com/platforms/glitz ### 01 Catalogue: A brand connects a store. Products import and stay in sync. The problem: A brand will not maintain a second catalogue by hand. If the store and the marketplace drift, the link a creator shared last week sells a product that no longer exists at that price. The decision: Shopify OAuth, import, sync and reconcile, with HMAC-verified webhooks for changes and draft orders on the way back. The brand does nothing after connecting. What it cost: A whole integration layer for one storefront platform: 3,493 lines in one directory, 11 of the 59 functions. It is a seam by folder, not by interface. The storefront's name is in the collection names and the security rules, so a second storefront would have to earn that interface first. ### 02 Link: A creator accepts a collaboration and mints a tracking link. The problem: The collaboration document is writable by the client, because the app needs to update it. The fairness feature (a seven-day grace at the old rate when a brand cuts commission) and the exploit (a creator setting their own rate to 99%) live in the same field. The decision: Cap the effective rate at the brand's product commission on the server, every time it is read for money. The client can write what it likes; the number that pays out is not the client's. What it cost: The grace window is now bounded by the cap too, and the comment on the cap says exactly what that costs the fairness feature so nobody widens it without knowing. ### 03 Attribution: A buyer taps the link, often inside another app's browser. The problem: Instagram's in-app browser, then the App Store, then a fresh install. The referrer does not survive that trip, and an unattributed sale is a creator who did the work and got nothing. The decision: A 48-hour bridge: the tap is recorded server-side by IP with the user agent alongside, the app asks once on first launch whether anyone at its address tapped a link, and the newest unclaimed match wins. The recording endpoint is rate-limited and only accepts links that exist, so the pool cannot be flooded or poisoned. What it cost: It is probabilistic. Two people behind one address installing within the window would be matched to the most recent tap, and the window and the rate limit are the whole defence. The note says so rather than pretending otherwise. ### 04 Charge: The buyer pays. A commission is minted against the link. The problem: A colluding brand and creator could round-trip a purchase of product X and mint a commission against a link for product Y. And the first version checked for an existing commission and then wrote one, so two calls for the same sale a few milliseconds apart would both pass the check and mint twice. The decision: Every commission must prove the payment bought this product. Commission documents get deterministic ids, keyed on the transaction rather than the payment intent because a multi-item cart shares one intent, so a duplicate create throws instead of paying. What it cost: Exactly-once assembled from a document store's create-or-fail, with the mint called from the checkout rather than a webhook, so a failed mint never blocks a buyer and can leave a creator's entry missing. It is the same shape as the payments platform's webhook fix, solved the same way, which is the point of showing both. ### 05 Hold: Money sits on the platform for thirty days before transfer. The problem: Splitting at charge time is simpler and wrong: a refund after the creator has been paid is a clawback nobody enjoys. Holding creates its own problems: float, stuck balances, transfers that fail. The decision: Take the full charge, transfer after the hold. Around that: a retry ladder from one hour to sixteen over five attempts, then a platform alert; a balance monitor; and a reconciler for payouts that got stuck between states. What it cost: A great deal more machinery than a split at charge. Every piece of it exists because a real case needed it. ### 06 Team: A creator's team splits the commission between leader and members. The problem: Three ways to cheat it: mint a solo link to dodge the leader's cut, send a tampered split from the client, or carry a split over 100% so the leader's share goes negative. The decision: A member's link is routed through their team automatically, earliest membership winning. The split is read from the team's own membership record, never the client. The split is clamped to 0..100. What it cost: Creators who genuinely want a solo deal alongside a team cannot have one. That trade was made on purpose and is written down. ### 07 Payout: Held money transfers to creators through connected accounts. The problem: Payout minimums are per brand, not per creator. Grouping by creator alone leaves money stranded under the minimum forever, and the claim on it has already been taken. The decision: Group by creator and brand, so each brand's minimum gates only its own money; groups under the minimum are skipped before anything is claimed and keep accumulating. Each entry is claimed in a transaction before its transfer, transfers carry deterministic idempotency keys, and marking paid is a separate step so a bookkeeping failure cannot be mistaken for a transfer failure. What it cost: Found by auditing a money path that was already live, not by design up front. Two of the seven findings in that pass were open ways to lose money. Amounts under the processor's transfer floor still just sit, and the code says so. ### 08 Rules: Seven hundred lines of security rules, treated as code. The problem: Parent rules do not cascade to subcollections. Without repeating the participant check, one collection-group query would return every private message on the platform. The decision: Privilege-escalation defence on role fields, a guest tier excluded from every write surface, immutable financial records, and the participant check repeated where the platform makes it easy to forget. Rules tested like code. What it cost: Repetition, on purpose, in the one file where a clever abstraction is more dangerous than a copy. ## MicroGym A live-operations tool for small-group training, built with a Toronto gym. Trainers assemble workouts, schedule sessions with a roster, then run the session live on a tablet while an assessment engine adapts every exercise to every member in the room. Stack: Next.js, Supabase, Postgres with row-level security. Status: Built March 2025 to July 2026 for one gym. No tests and no CI, said plainly.. Page: https://dylanpulver.com/platforms/microgym ### 01 Library: Trainers build a movement library, per gym, with a body area and defaults. The problem: A new member has no history, so nothing downstream can suggest a target for them. Leaving the cell blank puts the typing on the trainer, in the room, eight times over. The decision: Every movement carries a body area and default targets at three strength levels (reps, weight, duration). A member's intake strength level picks the default until their own history exists to replace it. What it cost: Fifteen extra fields on every movement, most of them empty for movements that never need them. The trainer fills them once. ### 02 Workout: Sets by rounds, assembled from the library, saved as templates. The problem: The same workout is run by eight people who are not the same. The template has to describe the intent, not the outcome. The decision: The template is the plan. The per-member version is never stored as a workout: substitutions are written when the session is created, from the plan plus each member's assessment answers, and resolved per member when the tablet renders the grid. What it cost: Two views of every workout, kept apart by structure rather than by a check: no session or substitution code path writes to the template's rows. ### 03 Assessment: Injuries and limitations per member, turned into substitution rules. The problem: Two assessments can fire rules against the same movement and disagree. Somebody has to win, and the choice has to be the same every time or the trainer stops trusting it. The decision: Newest assessment wins. A same-date tie keeps whichever rule was seen first, and the comment on that branch admits the order is arbitrary but consistent. What it cost: Admitting the arbitrariness in writing, and one more thing the comment does not say: the rule query has no ordering clause, so the consistency rests on the database returning rows in the same order. Noted here so the next person does not trust it further than that. ### 04 Roster: A session is scheduled and members are placed on it. The problem: A member with no history has no basis for a target weight. Pre-filling nothing means the trainer types everything; pre-filling a guess means being wrong in front of a client. The decision: History first: a one-rep-max estimate from recent sets suggests the next target. No history: the movement's default for the member's strength level stands in. Either way the number is a suggestion the trainer overrides with one tap. What it cost: The estimate is the Epley formula, which is known to drift at high repetitions, and the code does not say so where it is used. It should, and this page said it did until the source was re-read on 4 September 2026. ### 05 Live: The session runs on a tablet. Timers, per-member entry, notes. The problem: Eight members, one grid, every cell a write, and the trainer's thumb is already moving to the next cell. Anything on the write path that stalls is felt in the room. The decision: Milestone checks and other side effects fire without being awaited from the performance write. The write lands; everything that reacts to it happens after. What it cost: A side effect can fail silently where a blocking call would have shown an error. Failures are logged rather than shown. The comment says the check must not block the write; that a failure is never surfaced is implemented, not written down. ### 06 History: Every set ever recorded, per member, feeding the suggestions. The problem: Aggregating a member's history on every session start could get slow. The decision: Aggregates are computed in application code, not SQL, and the comment next to it says: fine at this scale, a member has tens of rows; move to an RPC or view if a member ever accumulates tens of thousands of performances. What it cost: A ceiling, named. Not a limitation hidden until it hurts. ### 07 Isolation: Every gym on the platform sees only itself. Enforced in the database. The problem: Multi-tenant security in row-level policies cannot be unit-tested the way code can, and it recurses the moment two tables check each other. The first version was wrong. The decision: A four-phase hardening, each phase with its own rollback file, the first verified by impersonating a member and counting the rows they could reach. Before: four rows that were not theirs. After: zero. Three tables were found with row-level security switched off and turned on. What it cost: Publishing the number four. It is the strongest thing on this page: the person who audits his own system and writes down what he found. # The notes, in full ## The webhook that credits twice A payment processor delivers every result at least once. The interesting engineering is in what happens when your side fails halfway through. Published 2026-09-02. https://dylanpulver.com/notes/idempotent-payment-webhooks Every card processor I have integrated makes the same promise about webhooks: at least once. Not exactly once. If they do not hear a success from you within some window, they send it again. That is the correct promise for them to make, because the alternative is losing a payment result in a network blip, and a lost result is worse than a duplicated one. But it means the duplicate is your problem, and on a payments platform a duplicate means a customer credited twice. The usual answer is a deduplication key: take some identifier from the delivery, store it, refuse to process anything you have already seen. Most implementations stop there, and most of them have a hole in the middle. ### The hole Picture the sequence. The delivery arrives. You claim the dedup slot so a retry will be refused. You start crediting the customer. The credit fails: a lock timeout, a bad row, a deploy landing at the wrong second. You return an error. The processor retries, as promised. Your handler sees a claimed slot and refuses it. The customer is now never credited, and nothing in your system knows. That is not a theoretical shape. It is what falls out of writing the dedup check first and the credit second, which is how everyone writes it, because that is the order you think about it in. The dedup key protects against the double credit and quietly creates the lost one. ### What the platform does instead The operation id is deterministic. It is derived from the delivery itself, so a retry of the same delivery produces the same id and collides with its own first attempt rather than being treated as new. A slot is claimed on arrival, as usual. Then the part that matters, on the newer processor's handler: if the credit fails, the slot is released and the response is a 503, not a 200 with an error inside. The older handler releases its slot on the chargeback path and answers 500, and on a failed credit it still logs and answers 200, which is on the list. A 503 is the one status that says, plainly, try again later. The processor does exactly that, the slot is free, and the retry gets to be the attempt that succeeds. So a retry is refused only when the first attempt actually finished. Where the credit function fails, the claim is undone. Two early returns after the claim, for a delivery with no customer identifier or no matching row, still answer 200 and leave the slot held: the same silent loss through a smaller doorway, and the next fix. That is the whole idea, and it fits in a sentence, but it is the sentence most implementations do not contain. ### The things around it The body of a webhook is unverified until you have looked up the right tenant's signing secret, and the only link back to a customer is a session id sitting inside that unverified body. So the handler has to find the tenant first, from the one field in the unverified body it can be looked up by, verify the signature with that tenant's secret, and only then trust anything else in the payload. One processor's documented signature scheme turned out to be wrong; the real one was reverse-engineered from a captured sandbox delivery, and the code says so, because a future reader will otherwise trust the docs and break it. An unknown tenant and a bad signature return the identical response. If they differed, someone could probe the endpoint and learn which tenant identifiers exist. That is a small thing to get right and an annoying thing to have gotten wrong. The costs are real. The deterministic id is a hand-rolled UUIDv5 rather than a dependency. The first version of the signature lookup was a loose scan for any header with signature in its name, which also matched the hosting platform's own proxy signature header; it was caught against a captured delivery and is now an exact-name lookup, with the reason written beside it. The signature lookup says why in its comment. The UUIDv5 says only that it takes no dependency, and should say more, so that a helpful person does not clean it up. ### Why it belongs on this page None of this is exotic. It is a handful of decisions, each obvious once stated, that only get made when the same person owns the endpoint, the ledger it writes to, and the customer support inbox that hears about it when it goes wrong. That is the case for one pair of hands, made with a webhook. ## A ledger you can edit from a route handler is not a ledger Why every movement of value on the payments platform goes through a Postgres function, and what it costs to keep it that way. Published 2026-09-02. https://dylanpulver.com/notes/ledger-in-postgres-functions Application code with a database connection can write anything. That is the whole problem. A route handler that can insert a row into the balances table can also, on a bad day, insert the wrong one, and no amount of care in the handler changes the fact that the capability exists. A ledger that can be edited from application code is a table with a hopeful name. ### The rule On this platform the money tables carry deny-direct-write policies. Nothing running as the browser's role can insert, update or delete a movement of value. Server code holds a service role the policies do not stop, so there the fence is convention: route handlers call functions and nothing else. All of it goes through Postgres functions, one for each kind of movement, and each one checks its preconditions, writes the entries it needs to write, and does so inside one transaction. The application asks; the database decides. There are a hundred Postgres functions in the migrations today, and roughly a dozen of them move money; the rest read analytics, fire emails and maintain timestamps. The hundred is on the front of this site with that label, because it sounds like a lot and it is meant to, and because the dozen is the number that matters. Every movement is written once, in the one place the browser cannot bypass. The fee rate is not: it is computed in the route handlers, in four places, and handed to the function as a parameter the function only sanity-checks. That is the next thing to move. ### Testing what cannot be unit-tested Functions and row-level policies live in the database, so the usual test runner does not reach them. The tests that exist run in pgTAP, inside Postgres: the deny policies on the balance table, and one ledger function from the earlier wallet model that the app no longer calls. A test impersonates a role, calls a function, and asserts on what happened, including what did not happen. It is the only way to know that the deny policy denies, and the live payment-rail functions do not have that test yet. Said plainly, because the reader sent to check will count the files. ### What it costs A migration for every change to a function. Slower to change than a line of TypeScript, and deliberately so. A ledger is the one part of a payments platform where speed of change is a cost, not a benefit. When someone asks for a small tweak to how a fee is calculated, the answer involves a migration file, a test, and a review, and that friction is the design working. It also means the application layer is thinner than people expect. There is no service class that mutates a balance. There is fee and referral arithmetic in TypeScript, which is the previous paragraph restated. And the money-out handler does more than validate, call and report: it checks a freeze flag and a blocklist, consumes the SMS code, claims the row, resolves the processor, and only then calls it. People reading the codebase for the first time go looking for the business logic and find it in SQL. That is the right place for it to be, and it takes a paragraph like this one to explain why. ### The pattern travels The same shape is on the other platforms on this page. The live-operations tool enforces tenant isolation in the database rather than in the app, for the same reason: the app is where mistakes are made, the database is where they are caught. It is not a payments idea. It is an idea about where to put the thing you cannot afford to get wrong. ## Four rows that were not theirs Hardening multi-tenant row-level security in four reversible phases, verified by impersonating a member and counting what they could see. Published 2026-09-02. https://dylanpulver.com/notes/row-level-security-hardening The live-operations platform is built to run more than one gym, and every gym must see only itself. That is enforced in the database with row-level security, which is the right tool and a difficult one. Policies cannot be unit-tested the way code can. They recurse the moment two tables check each other. And the first version of them was wrong. ### How wrong Four rows. Signed in as a member, with the policies in place and passing every glance, the member could reach four rows that belonged to someone else. Not a breach, not a leak anyone noticed, but four rows more than zero, and zero is the only acceptable number. Three tables were also found with row-level security switched off entirely, which is the failure mode the feature invites: a table is added, nobody turns it on, and the default is open. ### Four phases, each with a way back The fix was done as four phases of hardening, each a migration with its own rollback file. Not one big migration, because a policy change that breaks a legitimate query breaks it for everyone at once, in production, and the rollback has to be a file you can run rather than a plan you can describe. Phase one dropped the permissive policies that had been quietly granting access with a condition of true. Phase two scoped the gyms table itself, which had been fully open, and introduced the helper that ends the recursion. Phase three closed the settings table while keeping the one insert that signup legitimately needs. Phase four turned row-level security on for the three tables where it had been off entirely. The check was the same each time: impersonate a member, run the queries the app runs, count the rows that came back that should not have. The number went from four to zero. Only the first phase's file records that count; the later phases carry no verification line, so the four-to-zero figure is the first phase's, and this note says so rather than claiming it for all four. That count is the test. It is crude, it is honest, and it is the thing the more elegant approaches skip. ### The recursion The specific trap is worth naming. Table A's policy checks table B to find the member's gym. Table B's policy checks table A to see whether the member is allowed to look. Postgres notices and refuses with an infinite-recursion error, or, worse, one policy is loosened to make the error go away. The way out is a security-definer function that answers the question, which gym is this user in, without going through the policies itself; a second one, added in the last phase, answers it for the admin who can hold more than one gym. The policies that cross tables ask those functions instead of each other. Three of the eight do not need to and check a column or a single subquery inline. ### Publishing the number The cost of this stage is written on the anatomy for that platform: publishing the number four. It would be easy to say the platform enforces tenant isolation in the database and leave it there, which is true. It is stronger to say that the first version let a member reach four rows, that the person who built it found them, wrote the fix in reversible phases, and counted the result. Anyone who has run a multi-tenant system knows which of those two statements they would rather hear from the person they are about to hire. ## The bug inside the fix A payout race, the fix for it, the bug inside that fix, and why it is the best argument I have for one person owning the whole path. Published 2026-09-02. https://dylanpulver.com/notes/one-pair-of-hands A customer cashes out. The platform asks the processor to send the money. Two requests for the same payout arrive together, as they do when someone double-taps or a client retries, and nothing stops both from reaching the processor. That is a double payment, in real money, and it is the kind of bug that decides whether a payments platform gets to exist. ### The fix The redemption row is flipped to an initiated state atomically, before the processor is called. An update with a condition: set the state to initiated where the state is still pending, or failed from an earlier attempt, and return the row. Two requests race; the database serialises them; exactly one update matches and returns a row; the other returns nothing and is told the payout is already in progress. Only the winner calls the processor, and every failure branch after that point compensates by flipping the row back. Textbook, and correct. ### The bug inside it It shipped, and every payout started failing as a conflict. Every single one, including the first request with no race at all. The update was matching, the state was flipping, the processor was never being called, and the customer was being told someone else got there first. The cause was in the HTTP data layer between the client library and the database, a layer below the code that had been written. When you ask that library to update rows and return them, it re-applies your filter to the rows that come back. The filter said state equals pending. The returned row's state was now initiated. So the library, helpfully, filtered the winning row out of its own result, the code saw an empty result, and an empty result meant somebody else had won. Nothing in the documentation says this. It was found by running payouts end to end, the commit says so, and explaining it meant reading below the code that had been written until the behaviour was visible, then restructuring the query so the returned row was fetched in a way the filter could not touch. The fix to the fix was a few lines. The fix landed twenty-six hours after the previous commit to that file, and two weeks after the race fix it was inside. ### Why this is the argument Consider how this goes on a team of specialists. A backend engineer writes the atomic flip and is right. A frontend engineer sees every payout return a conflict and files a bug against the backend. The backend engineer reproduces it, confirms the update works in SQL, and sends it back. Somebody eventually suspects the client library, but nobody owns the client library, and nobody's job description includes reading its source. The bug lives for a sprint, then two, while payouts are manual. When one person owns the checkout, the route handler, the query, the database function, the data layer's behaviour and the row the customer sees afterwards, there is nowhere for the bug to hide between people. There is nobody to hand it to. You read the source because it is the next thing on the path, and the path is yours. That is what the line at the top of this site means. Not that one person types faster than five. That there is a kind of bug, and a kind of decision, that only gets found by somebody who is standing on every layer at once. This was one of them. ## The sale that arrives with no referrer A creator shares a link on Instagram, the buyer taps it, installs the app and purchases. Nothing in that chain carries the creator's name across. Here is what does. Published 2026-09-03. https://dylanpulver.com/notes/attribution-through-an-in-app-browser A marketplace that pays creators a commission has one promise underneath everything else: if your link made the sale, you get paid. On the web that promise is cheap to keep. The link carries a code, the code lands in a cookie, the cookie is read at checkout. On mobile, through a social app, it falls apart at every step. The buyer is inside Instagram. They tap the link and it opens in Instagram's own browser, not theirs. The page offers the app. They tap through to the App Store, install, and open something that has never seen the link, the cookie, or the code. The referrer did not survive the trip. From the platform's side, a stranger just installed the app and bought something, and the creator who did the work gets nothing. ### The two ways out The standard answer is an attribution vendor: an SDK in the app, a script on the page, and a fingerprint service in the middle that matches one to the other. It works, it costs money per install, and it puts a third party between the platform and the one number it cannot afford to get wrong. The other answer is to build the small version yourself, and the small version is small. ### What the platform does The tracking page looks at the user agent. A normal browser gets redirected to the product with the code in the URL, and a cookie takes it from there. An in-app browser gets something else: before the page renders, the visit is recorded with the visitor's IP address, the user agent, the code, and a 48-hour expiry. The page then offers the app. When the app opens for the first time, it asks the server one question: has anyone at my address tapped a link in the last two days that nobody has claimed? If so, the newest one is marked claimed and its code is handed back. The app treats that code exactly as if the link had arrived directly, holds it through sign-up, and applies it at the first purchase. The question is asked once per install, with a cooldown, and not at all if a real link is already waiting. That is the whole mechanism. The user agent is stored beside the record but the match is the address and the clock. No SDK, no vendor, one collection of short-lived documents that a scheduled job sweeps. ### What broke it, and what that added One social platform resolves links on its own servers before showing them to the user, as a safety measure, and follows the redirect. The visitor never touches the tracking page, so the record is never written. The fix was a second capture point: the product page itself, when it arrives with a code, posts the record from the browser. That page is statically cached, so the recording had to be a separate endpoint rather than something done while rendering, or the page would have stopped being static for everyone to serve one social app. Then the endpoint itself. It has to be unauthenticated, because the visitor is a stranger, and anything unauthenticated that writes to a pool used for matching can be flooded or poisoned. So it is rate-limited per address, the code has to have the right shape and has to resolve to a live link, and the user agent is truncated. The rate limiter runs in a transaction, and if that transaction fails the request is allowed through. That was a decision, written next to the limiter: for this endpoint, dropping a real tap costs more than admitting an extra one. ### What it costs It is probabilistic. Two people behind the same address, a household or an office, both installing within the window: the second gets matched to the first's tap. The window and the rate limit are the whole defence, and for a commission that is held thirty days before it pays out, the platform can afford to be occasionally generous to a creator rather than reliably unfair to all of them. That trade is not hidden. It is written here and it is written in the anatomy. ### Why it belongs on this page The problem spans a web page, a hosting platform's cache, an API endpoint, a database, a mobile app and the behaviour of somebody else's in-app browser. Each piece is simple. The reason it works is that one person could hold all six at once and move the fix to whichever layer was cheapest. That is the case for one pair of hands, made with a link that lost its referrer. ## The minimum that belongs to someone else Held commissions are released to creators once a brand's payout minimum is met. Group the money by creator, the obvious way, and one brand's minimum ends up holding another brand's money. Published 2026-09-03. https://dylanpulver.com/notes/payout-minimums-per-brand On the creator marketplace, a commission is not paid the moment a buyer pays. It sits in a ledger for thirty days first, so that a refund is a cancelled entry rather than a clawback from someone who has already spent the money. The hold stage of the anatomy covers why. This note is about what happens on day thirty-one. A scheduled job runs each morning, selects up to fifty held entries whose hold has expired, and transfers each one to the creator's connected account at the processor. That is the whole job in one sentence. Everything interesting is in the conditions around it. ### Whose minimum Brands set a payout minimum in the app. It is stored per brand, defaults to fifty dollars in the interface, and zero means none. The intent is ordinary: a brand does not want to pay processing overhead on a stream of three-dollar transfers, so commissions accumulate until they are worth sending. The first version of the release job grouped a creator's expired entries together and tested the total against a minimum. Ask which minimum and the design falls apart. A creator who sells for two brands has two minimums. Apply the first brand's and the second brand's money waits for a threshold the second brand never asked for. Apply the higher and the same thing happens the other way round. There is no correct answer to that question because the grouping is wrong: the minimum is a property of the brand, so the money has to be grouped by brand too. The job now groups by creator and brand together. Each brand's minimum gates only that brand's money to that creator. The comment above the loop says exactly this, in one line, and it is the kind of line that took longer to arrive at than to write. ### Skip before you claim A group that lands under its minimum is skipped, and the skip happens before anything is touched. The entries stay held, the job logs the shortfall, and they accumulate until a future run finds the total over the line. Nothing to undo, because nothing was claimed. This is the cheapest kind of correctness: put the check where a failure leaves no state behind. For a group that passes, each entry is claimed inside a transaction before its transfer is sent. The transaction re-reads the entry, checks it is still held, and flips it to processing. A second copy of the job running at the same second reads processing instead of held and leaves the entry alone. Then the transfer goes out with an idempotency key derived from the ledger entry's id, so if the same entry is ever sent twice the processor returns the first transfer rather than making a second one. Marking the entry paid is a separate step after the transfer, and the separation is deliberate. An earlier version wrote the paid status inside the same try block as the transfer, so a failure in the bookkeeping write looked exactly like a failure in the transfer, the entry was marked failed, and the hourly retry sent the money again. Now a bookkeeping failure raises an alert and leaves the transfer alone, because the transfer already happened and the idempotency key makes a re-run harmless. ### The floor under the floor The processor refuses transfers below fifty cents. An earlier version of the job skipped the transfer for those entries and marked them paid anyway, which is the quiet way to lose money: nothing fails, the ledger says paid, and the creator never sees the cents. Now the claim is reverted and the entry goes back to held. The comment beside that code says plainly that amounts under the floor just sit, and that batching them per creator is the fix if it ever matters. It has not mattered yet. The note is there so nobody rediscovers the question. ### When it still goes wrong A failed transfer enters a retry ladder: one hour, two, four, eight, sixteen, five attempts in all, run by an hourly job that claims each failed entry with the same transaction pattern. After the fifth failure the entry is marked permanently failed and an alert is written, which emails the team with a sentence that says the transfer needs a person. A reconciler runs every thirty minutes looking for entries stuck in processing or retrying for longer than that; a crash between claiming an entry and writing its final status would otherwise leave it in a state no query selects. Those are reverted to held and the daily job picks them up again, safely, because of the idempotency keys. A balance monitor checks the platform account each morning and alerts if it drops under a threshold, since a transfer from an empty account fails in a way that looks like every other failure. ### What it costs A daily job, an hourly job, a half-hourly job, a balance check and an alert pipeline, where a split at charge time would have been a single write. Every piece is there because the money path was audited after it was already live, and the audit found seven items, two of them open ways to lose money: the double-send on retry described above, and a transfer skipped for a creator who had not finished onboarding but still marked paid. The grouping fix, the reconciler and the floor handling landed in the same change in August 2026. ### Why it belongs on this page None of these seven items was visible from one layer. The double-send needed someone who knew the retry schedule and the shape of the transfer call. The grouping needed someone who knew where the minimum was stored and who set it. The floor needed someone who had read the processor's limits and the ledger's status machine. They were found in a single pass by the person who had written all of it, reading it as one system, and that is the argument this whole site is making. ## The commission that must not mint twice A buyer pays and the platform owes a creator a commission. Between those two facts sit a colluding pair, a call that arrives twice, and a cart with three items on one payment. Published 2026-09-03. https://dylanpulver.com/notes/exactly-once-commission-on-a-document-store On the creator marketplace a sale works like this. A creator shares a tracking link, a buyer taps it and pays, and the platform writes a commission entry into a ledger, where it sits for thirty days before it is paid out. The entry is minted by a server function that the checkout calls once per cart line after the payment has succeeded. The processor's webhook does not mint anything; it writes an audit row. That ordering has consequences, and most of this note is about them. ### Three ways to mint a commission nobody is owed The first is the direct call. The mint is a function any signed-in client can invoke, so without a check anyone could call it with an invented sale amount and a link code they never earned. Every caller must therefore present a payment intent id, and the function verifies it with the processor before doing anything else: the intent exists, its status is succeeded, its metadata names the caller as the buyer, and the sale amount being claimed is not more than the amount actually charged. The comment above those checks says what they close: direct-call commission minting, a fake sale amount or an unearned tracking code. The second is collusion. A brand and a creator who are in it together could make a genuine purchase of a cheap product and then call the mint with the tracking link for an expensive one. The payment is real, so the checks above pass. The function has to prove that this payment bought this product, and it does so by looking up the platform's own order records: same payment intent, same buyer, same product. No matching record, no commission. The comment names the attack in one sentence, a colluding pair round-tripping a purchase of product X to mint against a link for product Y, which is the kind of sentence that only gets written by someone who has sat and thought about how they would rob their own platform. There is a nuance worth stating. The proof reads the platform's records, not the processor's line items. That is sound only because those records are written server-side, when the order is created, under document ids derived from the payment intent and a line index. The client cannot choose those ids and cannot write those documents, so a record that matches is a record the server put there. The third is the ordinary one: the same mint arriving twice. A retried request, a double tap, two tabs. The first version of the function checked the ledger for an existing entry and then added one. That is the order everybody writes it in, and it has the hole everybody's version has: two calls for the same sale a few milliseconds apart both pass the check and both add an entry. The fix is a deterministic document id and a create rather than a set. Create fails if the document already exists. The second call catches that specific error, logs it, and returns success with a flag saying the commission was already processed. No transaction, no lock: the database's create-or-fail is the lock. ### Which id The obvious key is the payment intent id, and it is wrong. A multi-item cart on the web checkout shares one payment intent across every item, so keying the ledger on the intent would mint the first item and refuse the rest as duplicates. The comment in the code says exactly that. So the key is the transaction id, which is unique per cart line because it is the intent id with the line index appended; the mobile app, which sells one item per order, does not pass one and falls back to the intent. It is a small decision and it is the difference between a cart that pays creators and one that quietly pays only the first of them. ### The small things beside it The commission rate is capped at whatever the brand set on the product, because a creator can write an inflated rate onto their own collaboration record and the platform must never trust that number above the brand's. The team split is clamped to between zero and one hundred, because a tampered link could carry a split over a hundred and make the team leader's share negative. And a sort-order bug was caught in a comment: document names sort line ten before line two, which would have handed the tenth item's commission call the second item's record, so the lines are sorted numerically before anything is aligned. None of these is clever. All of them are the sort of thing that is only found by reading the money path as one piece. ### What it costs The mint is called from the checkout, and the checkout is not allowed to fail because of it: a mint error is caught and the buyer completes their purchase. That is the right trade, buyer over bookkeeping, and it means a creator's entry can be missing until someone notices. The proof of purchase is against the platform's own records rather than the processor's line items, which is a dependency on the order-writing code staying honest. And the race was closed a month after the collusion check landed, in the same pass that hardened the payout path; the summer of 2026 was spent finding these, one at a time. ### Why it belongs on this page This is the same shape as the webhook decision on the payments platform, described in its own note: derive the id from the event itself so a retry collides with its own first attempt instead of being treated as new. Two platforms, two databases, one idea, and the fact that it was recognised as the same idea the second time is the thing this site is trying to show. That happens when one person owns both ledgers. ## The rule that has to be written twice Firestore security rules do not cascade to subcollections. Forget that in one place and a single query returns every private message on the platform. Published 2026-09-04. https://dylanpulver.com/notes/security-rules-do-not-cascade On the creator marketplace the apps talk to the database directly. There is no API layer between a phone and a document, which is the normal shape for a Firestore app, and it moves the entire authorisation model into one file of 696 lines. Every question of who may read or write what is answered there, in a rules language with no loops and no shared state, where a mistake is not a bug in one screen but a hole in the platform. Most of the decisions in that file come from one property of the language. ### Rules do not cascade A conversation between two users is a document, and its messages are a subcollection under it. The conversation rule says a reader must be one of the two participants. The natural assumption is that the messages inherit that. They do not. A rule matches a path and nothing else, so the messages collection starts with no rule at all. Firestore also offers collection-group queries, which read every subcollection of a given name across the whole database in one call. Put those two facts together and an unguarded messages subcollection means one query returns every private message on the platform. The comment above the message rule says exactly that, and then the participant check is written out again: the reader's id must be in the parent conversation's participant list, fetched with a get on the parent document. It is written a third time for a top-level messages collection that neither app uses any more. Rather than delete it and hope nothing still points there, it is locked: reads gated on the same lookup, every write denied. The sender check on the subcollection carries one more condition, that the message's owner field equals the caller, because without it any signed-in user could inject a message into any conversation attributed to anyone, and since message updates are denied the forgery would be permanent. There is a shared participant helper elsewhere in the file. It is not used here. It checks the buyer, creator and brand fields of a sale, not the two-person list on a conversation, and pretending those were the same check is the abstraction that eventually lets one through. ### A grant is not an edit A user owns their profile document and may update it. The profile also carries the flags that make someone an administrator, a founding member, an approved account or a paid tier. The first version of the defence was the obvious one: those keys may not change. It broke the mobile app, which writes the whole profile back on every edit and omits the server-managed keys it does not know about. To that rule, a founding member editing their own profile looked like a founding member dropping their own flag, and the write failed. So the rule blocks a grant, not a change. A privileged flag may be absent or unchanged; it may not go from false to true. Three functions carry this: one on create, so a new profile cannot arrive privileged; one on every update, allowing removal and refusing a flip; one on the owner's own edits, which pins the account type so a buyer cannot promote themselves to a brand, and pins the field tying a creator to the brand that referred them, since that is set by a server function after checking the code and a client asserting it could attach itself to any brand. One flag is deliberately left out of the create check. Approval may arrive true on a new profile because the signup flows set it client-side, and the comment says so, so that the next reader does not fix it. ### The guest tier Guest checkout on the web signs the visitor in anonymously, so the simplest rule of all, that the caller is authenticated, is true for someone who has typed nothing but a card number. Every write surface in the file is therefore gated on a stricter helper: authenticated and not anonymous. Guests get their own profile, attributions, transactions and addresses, and the guest profile may only be created with a fixed list of keys, a buyer account type and an empty username, so a guest cannot squat a username the login lookup would later trust. Guests may not write an order at all. A fabricated order document would look like a paid one, so guest orders go through a server function that verifies the payment with the processor before anything is written. ### Money is written by the server The commission ledger denies every client write; entries are minted only by server functions, which bypass the rules. A sale can be created by its buyer and then never updated or deleted from a client. An order can be updated by the brand across the fulfilment fields only, never price, address, product or buyer, and the buyer's one permitted change is to flag a return. Tracking links cannot be created by a client, because the only legitimate path is the server function that authorises them, and anyone but the link's owner may touch nothing except the click counters, which stops a link's creator id being rewritten to hijack another creator's commission. The history of commission rates is append-only. Most of these lines are one word long, false, and each has a comment saying what it would cost to change it. ### Tested like code, run by hand The rules have a test script: 35 checks against the local emulator, each asserting that a specific write is allowed or refused. The interesting ones read like the threat model. A guest cannot write a transaction. A full user cannot plant a payment record for another user, with their own id on the document and the victim's id inside it, which would have surfaced in the victim's checkout query. An administrator cannot grant administrator from the client. The script seeds the documents the denial tests target, so a refusal is attributable to the rule and not to a missing document, and it takes a path to an older rules file so a change can be proved to fail before and pass after. Said plainly, as the rest of this site tries to be: the script runs when someone types the emulator command from the comment at the top of the file. There is no continuous integration on the repository and no package script that invokes it. The file has 41 commits since January 2026, and the comment about collection-group queries landed in a commit titled closing access-control holes, in the same pass that hardened the money path described in the other two notes on this platform. ### What it costs Repetition, on purpose. The participant check exists three times because the one file where a clever abstraction is most tempting is the one where it is most dangerous. Every get inside a rule is a document read, paid for and waited on, so the message rules pay for their own safety on every call. And the shape of the mobile app's writes reaches all the way into the security model: the grant-not-change rule exists because a full-document write from a phone drops keys it does not know about. That is the argument of this site again. The person who wrote the phone's profile screen and the person who wrote the rule that refused it were the same person, and the fix took one commit instead of a meeting. ## Thirty days in the ledger Splitting a payment at charge time is simpler and wrong: a refund after the creator is paid is a clawback nobody enjoys. Holding the money creates a different set of problems, and each piece of machinery around the hold answers one of them. Published 2026-09-04. https://dylanpulver.com/notes/thirty-days-in-the-ledger When a buyer pays on the creator marketplace, the platform takes the whole charge and writes a commission entry into a ledger with a status of held and a release date thirty days out. Nothing moves to the creator that day. The comment above the write says why in one line: it protects the platform from refund losses. A refund inside the hold cancels the entry, and that is the end of it. A refund after the money has gone out means reversing a transfer that has already landed in someone else's account, and the code for that path ends in an alert that says, in so many words, manual clawback required. The hold exists so that alert is rare. ### The release A scheduled job runs every morning and asks the ledger for held entries whose date has passed, fifty at a time. It groups them by creator and brand, because payout minimums are set per brand and one brand's minimum must not hold another brand's money, which is its own note. Then, for each entry, it claims it inside a transaction: read the entry fresh, confirm it still says held, flip it to processing, and only then move money. Two overlapping runs cannot both take the same entry, because the second one reads processing and steps away. The transfer itself is one shared function used by every path that moves money out. It refuses amounts under the processor's floor and leaves them held rather than marking them paid. It refuses when the creator has no connected account, for the same reason. And every transfer carries an idempotency key built from the ledger entry's id and the role being paid, so if the same entry is ever sent twice the processor returns the first transfer instead of making a second. Marking the entry paid is a separate step, after the money is out, and if that bookkeeping write fails the function writes an alert and still reports success. Reporting failure there would trigger a retry of a transfer that had gone through, and the idempotency key would catch it, but the point is not to need catching. ### The ladder When a transfer fails, the entry is marked failed with the error text, a retry count, and a time for the next attempt: one hour times two to the power of the attempts so far. One hour, then two, four, eight and sixteen. A second job runs hourly and picks up failed entries whose time has come and whose count is under five, claims each one the same way, failed to retrying, and calls the same transfer function. After the fifth failure the entry becomes permanently failed and a platform alert is written, which a third function turns into an email marked critical to one internal address. There is no array holding that ladder and no constant holding the five. Both are inline, twice, and a future reader has to know that. ### The reconciler A claim is a promise to finish. If the function crashes or times out between flipping an entry to processing and writing its final status, that entry is orphaned: no query selects processing, so nothing will ever touch it again. A job every thirty minutes looks for entries in processing or retrying that have not been updated in thirty minutes and puts them back to held, where the morning job will find them. It does not ask the processor what happened and it does not move money itself. It can afford not to, because the idempotency keys make a re-run safe: a transfer that did go out is reused, not doubled, within the processor's window for that key. That job arrived in August 2026, in the pass that hardened the whole payout path, five months after the hold itself. ### The balance monitor, said plainly A daily job reads the platform's available balance and writes an alert when it falls under a threshold, five hundred by default, changeable from a config document. That is all it does. It does not add up the held entries coming due and compare, which is the monitor a reader might assume from the phrase. It is a smoke detector, not a forecast, and the honest description is the one that lets the next engineer decide whether a forecast is worth building. ### What it cost A great deal more machinery than a split at charge time. Nine statuses, written as string literals with the transitions implied by scattered updates rather than declared anywhere: held, processing, paid, failed, retrying, permanently failed, cancelled, and two for clawbacks. The mobile app's model still documents the four it had in March, and its dashboard renders seven of the nine. The hold length is a literal in two files, so changing it means changing both. And none of this has an automated test; the pre-launch checklist verifies the release by editing a hold date in the console and watching the morning job. Every one of those is a real cost, written down here so the reader does not have to find it. The shape is the same one that appears twice already on this site, in the webhook note and the commission note: derive the key from the thing itself so a retry collides with its own first attempt. Here it is the third time, on the outbound side of the money, and it is the reason a reconciler can be a status flip instead of a forensic exercise. One idea, carried the whole way through one platform by the one person who wrote all three ends of it. ## Arbitrary but consistent A gym's assessment answers become rules that swap an exercise for a member before a session. Two rules can disagree about the same movement. The code says who wins, and the comment admits how. Published 2026-09-04. https://dylanpulver.com/notes/arbitrary-but-consistent The live-operations tool runs a small-group session on a tablet: one workout template, eight people in the room, and a grid where the trainer records every set. The people are not the same, so the template cannot be the plan for any one of them. The piece that turns one template into eight plans is an assessment and substitution engine, 1,507 lines in one file, and this note is about the decisions inside it, including the ones it gets only mostly right. ### A living profile, not a form An assessment here is not an event with a date and a score. A gym defines a flat list of questions, and every member carries a current answer to each one. The comment at the top of that service states two invariants and says that breaking either makes substitutions silently stop working: a member's answers must sit on the same template as the questions they answer, and an option's value is an immutable join key, never a display position. That second one is the kind of rule that is obvious once written and expensive to discover by moving an option in a list. ### The rule A substitution rule is four things: a movement, a question, an answer value, and the movement to use instead. That is all. There is no severity, no body area, and only one action, swap. Reducing the load, skipping the set, capping the reps, none of that exists as a rule. An older path keyed on a fixed list of injury types sits beside it. The narrowness is a choice: a rule a trainer can read in one line is a rule a trainer will write. ### Who wins The rules run once, when the session is created. The engine collects each member's most recent answer per question, finds every rule that matches, and then faces the conflict the title is about: two answers can point the same movement at two different substitutes. The resolution is newest assessment wins. When the dates are equal, the branch is empty except for a comment: keep the existing one, arbitrary but consistent. I would rather have that comment than a rule pretending to be principled, and I would also rather tell you the part it does not say. The query that fetches the rules has no ordering clause, so which rule the engine sees first is whatever order the database returns, and the consistency the comment promises rests on that order not changing. Nothing enforces it. Writing that down here is cheaper than the afternoon someone will otherwise spend on it. ### What the engine may not touch The engine writes one thing: a default substitution row for a member and a movement, and only if none exists. It never writes to the template. That separation is structural rather than a check, because no session or substitution code path writes to the template's rows at all, which is a stronger guarantee than a guard someone can remove. A trainer can override any default for one session, and the session-scoped row wins over the engine's row when the grid renders. If the engine fails while a session is being created, the failure is logged and the session is created anyway, because a session without automatic substitutions is still a session, and no session at all is a room full of people waiting. ### The number in the cell Each cell on the tablet needs a suggested weight before the trainer types one. With history, the engine estimates a one-rep max from the member's last set with the Epley formula, inverts it for the target reps, and rounds to the nearest five. Without history, the movement carries default targets for three strength levels and the member's intake level picks one. When a substitution is active, the defaults come from the substitute movement, not the original, which is the detail that stops a member with a knee limitation being handed a squat's numbers for a hinge. Epley is known to drift at high repetitions and the code does not say so where it is used. It should. This site said it did, until the source was re-read for this note. ### What it cost The file has fourteen exports and three of them have a caller outside it. Two comments left behind by an assisted edit, of the kind that says keep all the rest of the existing functions exactly as they are, are still in the live file. The generated database types name a table or two the code no longer reads, and the code reads a few the types no longer name, so the types were last regenerated in July 2026 and the ledger of thirty-four tables carries that caveat. There are no tests over the engine, the tie-break, or the arithmetic, and no continuous integration, said plainly on the platform's own card. The gym runs its sessions on it every week regardless, which is the argument for fixing the list above in the order it is written. The pattern the site keeps returning to is here again, on the other side of the money and the security policies: the person who wrote the assessment questions, the rule table, the grid and the arithmetic is the same person, so a wrong number in a cell is one repository away from its cause. The corrections on this page are proof of the same thing from the other direction. The anatomy rows for this platform were written partly from memory, the source was re-read on 4 September 2026, and four of them were changed to what the code shows. ## The number that ducked every threshold A deposit arrives from a browser carrying two numbers. The platform scored one of them and charged the other, and every limit it had was measuring the wrong figure. Published 2026-09-28. https://dylanpulver.com/notes/risk-checks-on-the-charged-amount On the payments platform, a deposit begins in a browser and ends as a charge on a card. Between those two events the request passes a guard: identity, a blocklist, velocity limits, a name match, and a risk score assembled from what the earlier checks found. The guard runs before anything is asked of the processor, so a request that fails it never becomes money. The request arrives carrying two amounts. One is the total the card will be charged. The other is the base figure underneath it, before the amount added on top. The browser computes both and sends both. ### Scoring one number and charging the other The first version of the endpoint took the base figure when it was present and fell back to the total only when it was absent. Every limit the guard owned then measured the base. The card was charged the total. The two are not the same number, and the gap between them is exactly the room an attacker needs: send a base small enough to sit under every threshold, and the charge that actually lands is larger than anything the guard agreed to. Nothing failed while that was true. No alarm exists for a limit that is being evaluated correctly against the wrong input. The checks passed, the charges settled, and the numbers in the dashboard were the numbers the guard had approved, which is the most comfortable way to be wrong. ### The fix is one line and a comment The endpoint now hands the guard the figure that becomes the card charge, and the comment above it says why: the declared split must not be the thing evaluated, because a small one ducks the thresholds while the card is charged the full total. That landed on 5 July 2026. The base figure is still accepted by the schema and still passed down to the processor adapter, where nothing reads it. It is dead weight now, kept only because removing a field from a request shape is a separate change with its own risk. ### The part that is still uncomfortable It would be tidy to say the lesson is never trust the client. That is not what happened here and it is not what the fix does. The total is also supplied by the browser. The server does not recompute it, does not derive it from the base, and does not assert that the two agree. What makes it safe to score is narrower than trust: it is the same number that gets charged, so understating it understates the charge too. Nobody can be charged more than they were scored on, because there is only one number doing both jobs. That is an invariant, and nothing in the code asserts it. It holds because one value is used for both purposes, and it would stop holding the moment somebody introduced a second path where the charge is computed from something else. The honest version of this decision is not that client input is untrusted. It is that the guard and the charge must read the same field, and that the design is safe exactly as long as they do. ### What distrust actually looks like here Elsewhere the platform does treat browser values as hostile, and the useful pattern is that omission is never rewarded. A device fingerprint that does not match the expected shape is discarded rather than stored, so a malformed one cannot pollute the graph that correlates customers by device. Leaving the fingerprint out entirely is not a clean result either, because the absence is itself a signal the risk score counts against the request. Otherwise dropping a field would be a free way to look unremarkable. The address a request arrives from is weighted more heavily than the fingerprint for the same reason: it is observed by the server rather than offered by the client, so it cannot be rotated away as cheaply. The same instinct runs through the rest of the path. A phone number is re-read from the verification record rather than taken from the request that claims it. When the processor reports a result, the handler binds the report to a checkout the platform originated and uses its own recorded amount; a mismatch is logged and the stored value wins, and a report about a checkout the platform never started writes nothing and raises an alert instead. The sensitive columns are pinned at the database, so the records the risk model reads cannot be written by the person being scored. ### Why it belongs on this page Every check in that guard was correct. The velocity rules counted properly, the risk model weighted sensibly, the thresholds were the right thresholds. The bug was one assignment, four lines above all of it, choosing which number to hand them. You do not find that by reviewing the risk logic, because the risk logic is fine. You find it by following a single figure from the form in the browser to the charge on the card and asking, at each step, whether it is still the same number. That is a whole-path question, and it only gets asked by somebody who owns the whole path. ## The second one tells you the shape High-risk merchants get dropped by processors, so the platform cannot be welded to one. The interface that makes a processor swappable could not be designed before the second one existed. Published 2026-10-02. https://dylanpulver.com/notes/extracting-a-payment-processor-adapter A platform serving high-risk merchants has a problem an ordinary one does not. Processors drop this category. Not for anything the platform did, and usually with notice measured in weeks. If the code is welded to one processor, that notice starts an outage that lasts as long as a rewrite. So the charge goes out through an interface rather than to a particular processor. There are two implementations behind it today and a third slot named but empty. Which one a given charge uses is a row in a table: active processors per merchant, a priority so one is primary and one is fallback, a weight so traffic can be split, and a per-customer override on top for when a single account needs pinning somewhere specific. ### It was not designed first The honest part of this story is the order it happened in. The first integration ran without any abstraction at all for about forty days. The interface, both implementations, the routing table and the generic endpoints all arrived in a single commit on the day the second processor was written. The first processor's code was not rewritten to fit the interface; a wrapper was fitted around it, and the design note of the day says so plainly. That is the opposite of how this is usually taught, and I think it is correct. An interface designed against one implementation is a description of that implementation. You cannot see which parts are the domain and which parts are one vendor's habits until a second vendor disagrees with the first. The second one tells you the shape. ### What disagreed Almost everything. One takes the buyer to a hosted page and returns a URL; the other returns a token to mount inside the page. One pushes payouts to a hosted flow, the other initiates them server to server with no screen at all. One keeps real customer objects, the other has no such concept, so the adapter hands back the platform's own user id in that slot. One talks in cents, the other in dollars, so the adapter divides on the way out and multiplies on the way back. One sends an event with a type on it; the other sends a settled transaction record with numeric codes and no type at all, so where the first adapter reads a name the second matches a pair of codes. What survived into the interface is small: set up a customer, start a deposit, start a payout, verify a webhook, parse a webhook. Five operations. Results are normalised to one closed set of outcomes, and anything that will not map is carried in a metadata bag rather than guessed at. The interface does not pretend the two are the same underneath. It publishes the difference as data, a field saying which checkout shape this processor uses, so the code that has to care can ask rather than guess. ### What leaked anyway An abstraction is honest only if you say where it failed, and this one failed in three places worth knowing. The parameterised webhook endpoint, one URL per processor per merchant, is the centrepiece of the design and one of the two processors cannot use it, because that provider appends its own path segment and will not accept a per-merchant URL. It has a second, hardcoded route that re-implements verification, deduplication and crediting. The generic path exists and serves one of the two. Disputes escaped the interface entirely. One processor reports them as events through the webhook. The other has no webhook for them, so they arrive by a polling job on a schedule, with its own cursor and its own credentials. The interface models neither, because there is no shape the two share. And the customer identifier, a single string on the interface, is doing a second job for one processor that it was not designed for, which is marked with a note in the code saying as much. One string was the right call for the common case and the wrong call for the thing that came later. ### What it bought The interface and the routing table have not been revised once since the day they were written. Every commit after that landed in an adapter or in one of the escape paths above. That is the actual measure of an abstraction: not that nothing changed, but that the changes stayed at the edges and the middle held. The sharpest lesson was not architectural. The second adapter's webhook handling was written from the provider's documentation, and when the first real delivery arrived in the sandbox, every assumption in it was wrong: the signature scheme, the header the signature arrives in, the shape of the payload, and the way a delivery is tied back to the checkout that started it. All four were replaced. Written from documentation, it would have rejected or dropped every deposit. Nothing in an interface protects you from that. Only a real delivery does. ### Why it belongs on this page Deciding to abstract a processor is a one-line decision anyone can make. Knowing which five operations survive, which differences belong in the open as data, and which three things will not fit no matter how the interface is drawn, comes from having integrated the first one badly enough to learn where it hurt. One person wrote the first integration, felt the forty days without an abstraction, wrote the second, and drew the line between them. That is why the line is in a defensible place.