# Zana Salimi, Senior Software Engineer, Full-Stack & AI > Senior software engineer building AI products end to end: React and Next.js frontends, Node.js and Go services, and LLM features that are tested and measurable. Relocating to Yerevan, Armenia; works remotely in GMT+4. Contact: hey@zanastack.com. Availability: Open to full-time, part-time or contract roles. Can start whenever needed. This file is the full text of every case study on https://www.zanastack.com. ## Answers ### Who is Zana Salimi? Zana Salimi is a senior software engineer (full-stack and AI) with eight years of production web work since 2018. He works remotely in GMT+4 and is relocating to Yerevan, Armenia. He builds AI products end to end: the interface in React, Next.js and TypeScript, the services behind it in Node.js (NestJS), Go and PostgreSQL, and LLM features that are tested and measurable. ### What does he work on right now? Two engagements. At ConnectMyTech, a SaaS that audits how often ChatGPT, Gemini, Perplexity and Google AI Overview recommend a business, he is the main engineer and made its LLM-based scoring accurate, deterministic and covered by tests. Wisp, which he co-founded with a partner, is a stablecoin payment gateway with a public and a private settlement path; he leads the merchant application and contributes to the Go service. Phase one is complete on testnet; it is not on mainnet yet. ### What does full-stack and AI mean in his work? The parts of a language-model product that decide whether people trust it. Prompts and the model integration, the fallbacks for when a provider is spent or slow, schema-constrained output instead of prose a parser has to guess at, evaluation that scores what the system said against what turned out to be true, and the interface that shows a person why an answer looks the way it does. ### What is his stack? TypeScript everywhere, React and Next.js on the front end, Node.js (NestJS) and Go for services, PostgreSQL, Supabase and Redis for data, and LLM APIs from OpenAI, Anthropic and Gemini, through OpenRouter or the Vercel AI SDK. Also wagmi and viem for anything on chain, Vitest and Playwright for tests, and Docker and GitHub Actions to ship it. ### Can I see the work? Six case studies cover it: Wisp, Trellion, Leveraged Mining, embertoast, sift and Cofield. Each one is written as engineering rather than marketing, with the decisions, the trade-offs they cost, and the parts that went wrong. They are at zanastack.com/projects. ### What has he open sourced? embertoast, a toast notification library for React that can be called from outside React and is published on npm; sift, a crypto research agent that scores its own calls against what the price did three days later; and Cofield, a collaborative whiteboard built on a CRDT, where the server relays edits and never arbitrates them. ### What do people who have worked with him say? Managers, product leads and designers from Trellion, Wisp, Leveraged Mining, PlanIt and Abroadin describe the same things. He takes ownership of the hard parts, builds designs faithfully, and steps up when a team is short. At Leveraged Mining, when the backend developer left, he "moved from frontend to full-stack almost overnight, cleared every blocker, and shipped a successful v1 on time." At PlanIt he "stepped up as a leader on our tech team." The full quotes are at zanastack.com. ### Is he available for work? Open to full-time, part-time or contract roles. Can start whenever needed. ### How do you get in touch? Email hey@zanastack.com, or book a call at cal.com/zanasalimi. He works remotely, in GMT+4, in full-time, part-time or contract roles. ## Experience ### Software Engineer, AI Systems & Full-Stack, ConnectMyTech Mar 2026 to present, Remote A SaaS that audits how often ChatGPT, Gemini, Perplexity and Google AI Overview recommend a business. I am the main engineer on it. I shipped the paid offering, and 332 audits have run so far, 220 of them paid. I went through six client reports claim by claim against the live sites, found about 130 claims that were false or contradicted each other, fixed the cause of each one and pinned the rule with a regression test. Scoring that moved a site between 80 and 85 on back-to-back runs now gives the same number twice, and a check that fails to fetch is left out of the score instead of counted as zero. Stack: Next.js, React, TypeScript, Supabase, PostgreSQL, Trigger.dev, OpenRouter, DataForSEO, Stripe, Vercel ### Full-Stack Engineer, Leveraged Mining Sep 2025 to Oct 2026, Remote Hashrate tracking for people who mine Bitcoin. I joined on the frontend and moved to full-stack when the backend developer left, stabilized the half-built Go API and shipped v1 in three months. I built the client portal for about 200 clients, the admin console and a partner platform that calculates commissions from CRM deals. Every call to the mining pool sat behind timeouts, retries and a circuit breaker, and admin rights were granted outside the web app, so a stolen browser session could not grant them. I also led v2 work: desktop layouts, logging templates, an earnings page and CSV exports for accountants. Stack: Go, Fiber, GORM, PostgreSQL, Supabase, React, Next.js, TypeScript ### Lead Engineer (Full-Stack), Trellion Nov 2025 to May 2026, Remote I built an AI interview platform as its only engineer in six months, from the Next.js app to the NestJS and GraphQL API, PostgreSQL and Redis, and delivered the MVP with 48 screens in production. During the pilot, 10 companies ran more than 37 interviews on it. Interviews were editable graphs that an AI video agent ran, and every AI score was stored with the prompt version and the model that produced it, so scores stayed comparable when prompts or models changed. Beside that sat the recruiter copilot, the job description and application generators, interview scoring and AI usage metering. Stack: Next.js, React, TypeScript, NestJS, GraphQL, PostgreSQL, Redis, Clerk, LLM APIs ### Frontend Lead, PlanIt Aug 2023 to May 2025, Remote A product studio building VC-backed Web3 projects. I was promoted from frontend developer to lead of a three-person frontend team, handled code review and one-to-ones, worked directly with founders and clients on scope, and kept writing code. I also ran hiring for the studio: more than 100 candidates interviewed, as the team grew from 3 to about 25 people across game development, design, frontend and backend. In three months I shipped the frontend of a VC-backed Web3 product whose smart contract held $1M in funds and reached more than 150 users. Stack: React, Next.js, TypeScript, wagmi, viem, ethers.js ### Frontend Developer, Abroadin Nov 2020 to Jul 2023, Remote A study-abroad platform. I led its migration from Vue to React and Next.js in two months, including a student panel of more than 25 pages for applications and admission estimates, then rebuilt the frontend through a full product redesign that made key user flows about twice as fast, measured with Lighthouse and tracking data. I also built the frontend of an AI university matching feature, created a design system of about 20 components with the designers, and set up the CI/CD pipelines. Stack: React, Next.js, TypeScript, CI/CD ### Frontend Engineer, Ava Cinema Mar 2019 to Oct 2020, Remote Sole frontend engineer in a two-person team. I built a streaming platform's frontend from scratch to production, including the component library, and a custom HLS video player for licensed content: streaming-only playback to protect copyright, quality switching, subtitles, playback speed and ad breaks at set points in the video. Stack: React, JavaScript, hls.js ### Frontend Developer, Freelance Mar 2018 to Feb 2019, Remote Web interfaces for a range of clients, including the music player, user panel and subscription packages for a music website with about one million daily visits. Stack: React, JavaScript ## What people who worked with him say > Zana played a key role in building the frontend experience for Trellion.ai. He took ownership of complex parts of the product, delivered clean and reliable solutions, and helped turn ideas into a smooth experience for users. His attention to detail and technical skills made a real impact on the project. > - Majid Mohisen, Trellion AI > I worked with Zana on our leveraged mining project, and he proved himself when it mattered most. When our backend developer left, he moved from frontend to full-stack almost overnight, cleared every blocker, and shipped a successful v1 on time. > - Shahab Papoon, Leveraged Mining > Zana turns designs into experiences. His frontend work isn't just functional, it's intentional. Every pixel has a reason. Rare to find that level of craft. > - Parsa Amini, Wisp > I managed Zana directly at Planit, and he made a real difference across our web3 projects. He's a versatile frontend and full-stack engineer with strong judgment, and he stepped up as a leader on our tech team. I'd gladly work with him again. > - Cash CN, Planit > Handing a design to Zana is the best part of any project. He's a one-man army, and I've yet to give him a design he couldn't build. His product design background means he understands the intent behind every screen, and what he ships is pixel-perfect and a joy to use. > - Mahdi Gharib, Designer, Leveraged Mining > I worked with Zana years ago at Abroadin. I was the Product Manager of the team, and Zana was our front-end developer. Back then, his commitment and quality of work were something that you couldn't miss. He was open to new ideas, super patient, and a very good communicator. > - Mahdi Memarpouri, Product Manager, Abroadin # Case studies ## Leveraged Mining: One system for mining clients, coaches and referral partners https://www.zanastack.com/projects/leveraged-mining | Published 2026-09-12, updated 2026-10-15 Four services behind one business: a Go API over a mining pool with no product API, a client portal, an admin console, and a partner platform that turns CRM deals into commission owed. - Role: Full-stack engineer, led v2 work - Timeline: Sep 2025 – Oct 2026 - Team: Solo build, client as product owner - Stack: Go, Fiber, Postgres, Next.js, Supabase ### Overview: One system for mining clients, coaches and referral partners Leveraged Mining sells hosted bitcoin mining alongside a coaching program. That means three groups of people who need completely different things from the same business. Clients want to see their machines earning. The team running the program wants to see who is engaged and who has gone quiet. Referral partners want to know what they are owed and when. I built the system that serves all three. I joined on the frontend, moved to full-stack when the backend developer left, and shipped v1 in three months; the client portal served about 200 clients. The engagement ran from September 2025 to October 2026. A Go API owns the data and the pool integration. Three Next.js applications sit on top of it: a client portal, an internal admin console, and a separate partner platform with its own database and its own login. They share nothing except the business they describe, which was deliberate. **The impact.** Before this, client mining numbers lived in a pool dashboard nobody could safely give a client access to, and partner commissions were worked out by hand against a CRM export. Now a client signs in and sees their own machines. A partner signs in and sees every deal attributed to them with the commission already calculated. The team stopped doing the arithmetic and started checking it. - 2: Databases, and one upstream nobody controls - 70: Test files on the money path - 29: Schema migrations shipped ![Admin dashboard: logged hours, active clients, total payout and the anomalies feed]() ### Problem: Numbers that existed, but nobody could safely reach Every number the business needed already existed somewhere. Mining performance sat with the pool. Deals sat in the CRM. Coaching hours sat in whatever the client happened to write down. None of it was reachable by the person who actually needed to look at it, and every workaround was some version of a human copying figures between two screens. The pool was the sharpest version of it. The product surface exposes the account view and nothing narrower, so anything per client had to be derived rather than requested. So the numbers were technically available and practically unusable, which is a worse position than not having them at all. ![Client portal on mobile: hashrate, payout totals and a payout history table]() - **No contract to build against.** The upstream surface carries no version and no compatibility promise. Anything built on it has to assume the shape can move. - **Access was all or nothing.** There was no way to show one client their own machines without exposing everyone else's alongside them. - **Payouts were reconciled by hand.** Every cycle, someone exported the CRM, worked through it in a spreadsheet, and hoped the two agreed. Nobody could answer why a figure was what it was six months later. - **Nobody could demo the product.** Showing the portal to a prospect meant showing either a real client's earnings or an empty account. ### Discovery: What the upstream turned out to be The first week went into understanding the integration rather than writing it. What came back changed the plan twice. The pool's data is real but the surface around it is not a contract, so nothing coming out of it can be trusted to keep its shape. That pushed every field through defensive parsing and pushed the whole integration behind one boundary, so the rest of the system would never learn what the upstream looked like. The second finding was better news. Underneath that surface, access can be scoped to one client's workers, which meant permissions did not need a bespoke model at all. Attach the scope to the client record and the question collapses into ordinary row ownership, which is a problem every part of the stack already knows how to answer. The CRM had its own surprise. The record the business calls a deal was not the record the earliest rows had been keyed by. A naive first sync would have duplicated every historical deal, which is the kind of thing you find in week one or explain in month six. ![Admin client list with package, max terahash, current hashrate, miner count and payouts per client]() ### System design: Two systems that stay out of each other's way Four applications, two databases, one upstream nobody controls. A request starts at a client, passes an edge that decides who is asking, reaches exactly one service that owns those rules, and only then touches data or something external. Solid lines are request paths. Dashed lines are out of band: a scheduled pull, a webhook, a notification that fires after the response has already gone out. The partner platform sits on its own database on purpose, so nothing in the mining system can read a partner row and nothing in the partner system can read a client's mining data. - Operator channel: Highest privilege actions - Client portal: Machines, payouts, logs - Admin console: Clients, packages, content - Partner portal: Deals and commission - Operator guard: Out of band, not a browser session - Session + role guard: Client and admin - Partner session: Separate credentials entirely - Go API: Accounts, activity, packages, content - Pool adapter: The only code that knows the upstream - Sync + commission: Pricing engine, run lock - Mining database: System of record - Mining pool: No product API - Partner database: Separate system of record - CRM: Source of deals - ops to opsg - opsg to api: admin lifecycle - portal to jwt - admin to jwt - jwt to api - api to pg - api to pooladapter - pooladapter to pool: scoped per client - partner to pmw - pmw to sync - sync to supa - sync to crm: scheduled pull + webhook Two rules hold the shape. Nothing skips a layer, and nothing reaches sideways into a sibling's data. A request handler knows about the web and nothing else. A service knows the rules and owns the money. Storage answers questions and holds no opinions. It sounds like bookkeeping until the sixth month, when the reason a package is still readable is that it never grew a shortcut. The pool adapter is the sharpest example. It is the only code in the system that knows the upstream exists. Every other package asks for a number and gets a number, or gets an error already phrased for a person. Nothing outside that boundary has ever had to care how the numbers are obtained, and when the upstream shifts, exactly one package changes. That boundary owns the traffic policy too, because it is the only place that knows the upstream is slow and occasionally hostile. Every call carries a deadline. Failures retry a bounded number of times with backoff and jitter rather than hammering something already struggling, and sustained failure opens a breaker so the system stops trying for a while instead of queueing up behind a dead dependency. The client dashboard is why that matters in practice. A home screen asks for several different figures at once, and the naive shape is one upstream call per card. Instead the adapter reads through a short lived cache scoped to the client, and collapses concurrent misses into a single in flight request, so ten cards rendering together produce one call rather than ten. When the breaker is open the portal serves the last good reading with its age on it, because a number from four minutes ago is worth more to a client than a spinner that resolves into an apology. **One request end to end** 1. **A client opens their dashboard.** The session is verified against current account state rather than trusted from the token alone, so access revoked a minute ago is revoked on this request and not at the next expiry. Fails when: An unverified or revoked session is refused before any business code runs. 2. **Identity comes from the session, never the request.** Whose data is being asked for is decided by the verified session and nothing else. There is no path where the answer comes from something the caller supplied. 3. **The service resolves the account.** A demonstration account never reaches the network at all. Its numbers come from a generated dataset instead, so the portal can be shown to a prospect without exposing anyone real. 4. **The cache answers first.** A recent reading for this client is served straight back. Several cards asking at the same moment share one lookup rather than racing each other upstream, and a miss is what actually reaches the network. 5. **Live reads go through the adapter.** The client's own scope is what the upstream is asked about, which is the mechanism that makes one client's view structurally incapable of containing another client's machines. The call has a deadline, a bounded retry, and a breaker behind it. Fails when: Timeouts, throttling and malformed answers resolve to one short sentence the client can act on, or to the last good reading with its age shown. The upstream's raw response is kept for diagnosis and never rendered. 6. **Parse defensively, degrade honestly.** Anything that does not match the expected shape is skipped rather than crashing the request, and an account with nothing in it yet reads as empty rather than as a failure. A new client with no machines is a normal state, not an error. 7. **Return one type.** Demonstration and live data return the same shape, so the interface has no branch for which one it is rendering. That is what stops the demo path from quietly rotting while nobody looks at it. **Privilege is a ladder, not a switch.** Clients reach their own data. Admins reach client data and program content. The ability to grant or remove an administrator sits above both, on a channel outside the web app entirely, so the most damaging action in the system cannot be reached by a stolen browser session no matter whose it is. > I worked with Zana on our leveraged mining project, and he proved himself when it mattered most. When our backend developer left, he moved from frontend to full-stack almost overnight, cleared every blocker, and shipped a successful v1 on time. > - Shahab Papoon, Leveraged Mining ### Correctness: Numbers that have to agree with themselves One question shaped most of the schema: store the total, or compute it. The obvious move for a number the admin console shows constantly is to keep a running counter beside it. I did not, because a counter is a second source of truth, and it goes wrong quietly the first time a record is edited or deleted. Nobody notices until someone asks why a figure moved. Anything describing the present is computed from the underlying records, every time. Anything describing a moment goes the other way. A figure that was published, acted on, or paid against is not a fact about now. It is a statement about what was true on a date, under rules that were in force on that date. Recomputing it later against today's rules would silently rewrite history the first time anything changed, so those figures are written down when they are settled and never derived again. That is the rule the whole model runs on. Describes the present, compute it and keep one source of truth. Describes a moment, record it and treat it as immutable. Getting that backwards is how reporting systems end up disagreeing with themselves, and why the answer to why is this number what it is turns into a shrug. **Money never touches a float.** Bitcoin amounts are held as satoshis: integers in the smallest unit, from the moment they are parsed to the row in the database, never a binary float that has already lost the last digit. Where a figure has to be rounded, it is rounded once, at the point it is decided, and the rounded value is what gets stored. Rounding at display time is how two screens end up disagreeing about the same record. Ingestion is the other half of correctness. Inbound events from the CRM are authenticated before anything is read out of them, and they survive a key rotation on the sender's side without dropping anything on ours. Each event carries an identity of its own, so arriving twice does not mean applying twice. At-least-once delivery is a promise every webhook makes and every integration eventually collects on. The scheduled reconciliation exists because webhooks alone are not a guarantee. A pull on a schedule closes the gap left by any event that never arrived, and because both paths write through the same idempotent step, a record that came in twice by two different routes still lands once. Pull and push together are what make the data eventually correct rather than usually correct. ### Decisions: The calls that were not obvious Four decisions did most of the work. Each one had an obvious option that would have been faster to ship and worse to live with. | Challenge | Obvious option | What I did | What it cost | | --- | --- | --- | --- | | Upstream with no contract | Call it from wherever the data is needed | One adapter that owns every fact about the upstream, including its traffic policy | An extra layer to write. It paid for itself the first time the upstream changed shape and exactly one package needed touching. | | A dashboard of many figures | One upstream call per card | Read through a per client cache, with concurrent misses collapsed into one in flight call | Readings can be a few minutes old. On a metric that moves slowly, that is a trade worth making for a page that paints immediately. | | Outsiders in the same system | One database, a role flag, shared sessions | A separate application with its own store and its own accounts | Two deploys instead of one. In exchange, no authorisation mistake on one side can reach the other. | | Re-keying live records | A one shot migration over everything | A resolver that prefers the stable key and falls back to the legacy one, adopting as it goes | The fallback path has to live until it stops being used. No downtime and no duplicated history. | The demo problem was the one I did not expect to be interesting. The team needed to show the portal without a real account behind it, and both obvious options were bad: a real client's account is a privacy problem, and an empty one sells nothing. So a demonstration account is served generated data instead, derived deterministically rather than randomly. The same account shows the same believable figures all day and moves overnight, which means a call can be run twice without the story changing underneath it, and a screenshot taken in the morning still matches the product in the afternoon. It returns the same shape as the live path, so the interface cannot tell the difference and the demo cannot quietly rot. ![Client activity log on mobile with the export sheet and a delete confirmation]() Scheduled work is the other place where the obvious version is wrong. The reconciliation runs on a schedule and can also be started by hand, which means it can overlap with itself. Both entry points claim a lock first, so a second run refuses to start rather than racing the first, and a run that dies without releasing is reclaimed on the next attempt instead of blocking the schedule forever. Two jobs writing the same record at the same time is the kind of bug that never reproduces and shows up as a number nobody can explain. Every long running job writes a record of itself: when it started, what it touched, whether it finished. That is not logging for its own sake. It is the difference between answering did last night's run work with a yes and answering it with a maybe. **Quality machinery.** Seventy test suites sit on the places where a mistake costs money or leaks something: calculation boundaries, record identity, event authenticity, and making sure fields that must never reach a browser never reach one. Twenty nine migrations hold the schema history, so the shape of the data has a past tense. Types sit at every boundary, input is validated at the edge rather than trusted inward, and privileged actions are recorded so there is always an answer to who changed what. ### Lessons: What building against someone else's product taught me The integration work was never really about the pool. It was about deciding where the uncertainty was allowed to live. Once every upstream response passed through one boundary that either returned a clean type or a sentence a human could read, the rest of the system stopped caring how solid the ground underneath it was. That generalises: an unreliable dependency is fine as long as exactly one place is responsible for making it look reliable, and that place is small enough to hold in your head. The other lesson was about separation being cheaper than it looks. Running the partner platform as its own application with its own database felt like extra work in week seven. It stopped feeling that way the first time a change to partner authentication shipped on its own, on its own schedule, with no chance of touching a mining client's session. **Where the time went: the upstream boundary.** Not on features. It went on the boundary around the upstream, on deciding which numbers are computed and which are recorded, and on making the same record arriving twice land once. Those three are why the system has stayed quiet, and none of them are visible in a screenshot. ## Trellion: An AI interviewer that hiring teams can actually reason about https://www.zanastack.com/projects/trellion | Published 2026-09-12, updated 2026-10-15 A hiring platform where the interview is a graph the team designs, the interviewer is a video agent that follows it, and every generated score arrives with a version stamped on it. - Role: Lead engineer (full-stack) - Timeline: Nov 2025 – May 2026; MVP built in six months - Team: Solo build: product and API - Stack: NestJS, GraphQL, Postgres, Redis, Next.js ### Overview: Two audiences, one hiring record Trellion is a hiring platform with an AI interviewer at the centre of it. A hiring team writes a role, designs the interview as a flow, and publishes it. A candidate applies, books a slot, and has a conversation with a video agent that follows that flow. Both sides then look at the same record: a transcript, a score with its reasoning, and coaching notes written for the person who sat the interview. I built the product and the API underneath it as the only engineer, in six months from November 2025 to May 2026, and delivered the MVP: 48 screens in production. During the pilot, 10 companies ran more than 37 interviews on it. A GraphQL API owned the domain and every integration. One web application served both audiences, because a candidate and a recruiter were looking at two views of one hiring record rather than at two products. **What it replaces.** The first screening round. Not the judgement, the logistics: the scheduling thread, the half hour on a call asking the same six questions, the notes nobody writes up, and the silence a candidate gets afterwards. The team still decides. They decide from a transcript, a scored breakdown they can argue with, and a record of what the candidate was actually asked. - 62: Test files in the API - 65: Schema migrations shipped - 48: Product screens in production ![Hiring pipeline board with candidates in stages and their interview scores]() **About these screens.** Every screenshot in this study runs on generated demo data. The names, roles and transcripts are invented, because hiring records are among the most sensitive data a product can hold and none of it belongs in a portfolio. ### Problem: Screening does not scale, and the usual fix is worse Screening is the part of hiring that scales worst. It is the same conversation repeated thirty times by the most expensive people in the company, recorded inconsistently, and compared from memory. The usual response is a keyword filter over CVs, which is fast and rejects the wrong people for reasons nobody can reconstruct afterwards. An AI interviewer sounds like the obvious answer and is a worse product than it sounds, unless three things are true. The team has to be able to see and change what the interviewer will ask. The score has to be explainable next to the answer it came from. And the candidate has to get something back, because a person who gave up half an hour deserves more than an automated rejection. ![Organisation job listings with status, compensation bands and application counts]() - **A black box is not reviewable.** If a hiring team cannot see the questions in advance, they cannot stand behind the outcome, and neither can their legal team. - **Generated text is not a fact.** A model will return a confident number for an empty transcript. Anything it produces has to be checked, versioned and attributable before it can sit next to a candidate's name. - **Two audiences, opposite access.** A candidate and the company hiring them use the same records from opposite sides, and one of them must never see what the other sees. - **Realtime work that fails midway.** A live video interview involves an external provider, a queue, and callbacks that arrive out of order. Any of it can fail while the candidate is still sitting there. ### Discovery: What the first build taught me The first version asked a fixed list of questions and scored the answers afterwards. It worked and it was useless. Every interview sounded the same regardless of what the candidate said, and a hiring team reading the results kept asking the same thing back: why did it ask that? That pushed the interview from a list into a graph. Nodes are the moments of the conversation, edges are how it moves between them, and some of those edges only open when a condition holds. The part that mattered was ownership: the graph belongs to the hiring team, not to me. They open it, read every prompt, change a branch, and the editor refuses to save a shape that cannot run. The second lesson was about the model layer. Treating generated output as text to render is what makes AI features rot: the prompt changes, the shape changes, and last month's stored result quietly means something else. Every generation in the system returns a validated object, and the schema version and prompt version travel with it into storage. The third was scheduling, which I expected to be a form and which turned out to be a rules problem: a session that has to sit inside the job's application window, a cap on how much of the future one person can hold, no overlap with their own bookings, and a reminder that has to survive the interview being cancelled after it was already queued. ![Interview workflow editor: question nodes, a conditional branch and guardrail settings]() ### System design: One API, three kinds of work One application, two audiences, and the services behind them. An authenticated request passes an edge that decides who is asking and whether this account may spend, reaches exactly one service that owns those rules, and only then touches storage or something external. Work that cannot finish inside a request is queued and comes back through the same services. Solid lines are request paths. Dashed lines are out of band: queued work, and provider callbacks that arrive minutes after the response they relate to. Everything a third party sends enters through one intake that verifies the signature and records the event before any domain code runs, so a forged or replayed payload never reaches a service. - Candidate views: One application, apply and interview - Recruiter views: Same application, roles and review - Public job pages: No account required - Provider callbacks: Interview and payment events - Session + role guard: Identity never comes from the request - Entitlement guard: Checked before work, recorded after success - Webhook intake: Signature checked, recorded, replay safe - GraphQL API: Resolvers thin, services own the rules - Interview orchestration: Flow, agent config, scoring - Background workers: Reminders, generation, provider sync - Usage + limits: Allowances, counters, reconciliation - Primary database: System of record - Cache, queues, realtime: One dependency, three jobs - Model provider: Structured output only - Interview video provider: Runs the live conversation - Payments provider: Owns account state - candidate to session - recruiter to session - session to api - publicjobs to api: public reads - session to entitlement: metered paths - entitlement to interviews - api to pg - api to redis: cache + realtime - api to interviews - interviews to model: generation + scoring - interviews to video: session config - api to workers: enqueue - workers to redis: queue state - callbacks to intake - intake to interviews: interview events - intake to usage: account events - usage to payments: reconciled on a schedule - usage to pg Two rules keep the modules apart. A resolver knows about the web and nothing else, and a service owns the rules for exactly one part of the domain. Cross-module work goes through another service's front door rather than reaching into its storage, which is dull right up until interviews, metering and applications all look like they need to change at once and only one of them actually does. The provider boundary is where most of the care went. One package knows how the video agent is configured, what the interview graph translates into, and how its callbacks are shaped. Everything else asks for an interview session and gets a typed object back, or an error already phrased for a person. When the provider changes its surface, exactly one package changes, and its tests are the ones that fail first. Realtime is the part users feel. A scored interview, a stage change, a new message and a finished generation all publish to subscribers over the same channel the queues run on, so a recruiter watching a pipeline sees a score land without refreshing. Nothing in the product polls for state the system already knows has changed. Four stack decisions carry most of the weight, and each one is worth defending rather than assuming. The stack calls. Every one of these buys something specific and charges for it somewhere else, and the third column is the part that matters in a review. | Call | Why, concretely | What it costs | How that cost is paid | | --- | --- | --- | --- | | GraphQL rather than REST | Two audiences read the same records in very different shapes. A pipeline screen wants candidates with their profiles, their roles, their latest interview and its score in one round trip; a candidate's own screen wants a slice of the same graph. Over REST that is either several calls per screen or several bespoke endpoints that drift apart, and the mobile layout would pay for it twice. | The classic N+1 explosion, no free HTTP caching, and a schema that is easy to over-expose. | Related lookups are batched per request, caching is explicit rather than assumed, and every field is behind the same guards as the query that reached it. | | One application for both audiences | Candidate and recruiter views share most of their surface: the same records, the same components, the same realtime channel. Two applications would mean two deploys, two sets of types and a slow drift between them. | Access rules have to be exact on every route, because the wrong view is now one URL away rather than one deploy away. | Access is decided on the server from the session, never from the route or a client flag, and the rules live in guards rather than in screens. | | One cache and queue dependency doing three jobs | Caching, background queues and realtime fan-out are all in the same place. One operational dependency to run, watch and reason about instead of three. | It becomes a single point of failure for three unrelated features. | Cache misses fall through to the database rather than failing, queued work is at-least-once and safe to repeat, and realtime dropping out degrades to a page that still loads. | | Postgres as the only store | Everything here is relational: people, roles, applications, interviews, events. Search, reporting and history all come off the same records, and that keeps one source of truth. | No dedicated search or analytics engine, so heavy reporting will eventually strain the same database the product runs on. | The read-heavy screens are batched and paginated, and adding a second store stays a deliberate later decision rather than an early habit. | **One interview, end to end** 1. **The team designs the interview.** Nodes, branches and guardrails are edited on a canvas and validated before they can be saved: no orphan steps, no node with two sequential next steps, no branch without a condition. The saved graph is then pushed to the provider, so the agent runs the same thing the team just read. Fails when: If the push fails, the previously saved version is restored, rather than leaving the product describing one interview while the agent runs another. 2. **A candidate books a slot.** Scheduling is checked against the job's application window, a duration ceiling, the candidate's own bookings, and a cap on how many pending sessions one person can hold. A reminder is queued for shortly before the start. Fails when: A failed reminder never fails the booking. Cancelling the session cancels the queued reminder with it. 3. **The session starts.** The agent's persona for this role is created on first use and reused afterwards, then a conversation is opened with the settings the role carries: language, duration, captions, and the graph itself. Fails when: If the agent cannot be configured, the session is refused before the candidate joins, rather than dropping them into a call that cannot work. 4. **Events arrive out of order.** Callbacks about one conversation do not arrive in the order they happened, and the provider does not send an id to key them by. Each event is given an identity derived from its own contents, written once under that identity, and the set of what has arrived is kept alongside it, so finalisation waits for the specific event it needs rather than for whichever happens to be last. Fails when: A repeat delivery collides with the identity already stored and is dropped. An event for a conversation nobody is waiting on is acknowledged and ignored rather than retried forever. 5. **The transcript becomes a score.** Scoring runs against the questions the graph defined, returns a validated object, and is stored with the schema version, prompt version and model that produced it. Coaching feedback for the candidate comes from the same transcript in the same shape. Fails when: Output that does not match the schema is retried and then refused rather than stored. A transcript with nothing in it scores nothing rather than inventing a number. 6. **The result moves the application.** A score above the role's threshold can advance the candidate automatically, a score below it can close the application, and both are written to stage history with a reason attached. The final stage always needs a person. Fails when: If the application has already moved on or been withdrawn, finalisation records the result without overriding the human decision that got there first. **Where cost enters the flow.** Generation-heavy actions check an allowance before they run and record usage only after they succeed, so a failed call never costs an account part of its limit. Limits come from configuration rather than from code, which means changing one is data and not a deploy. > Zana played a key role in building the frontend experience for Trellion.ai. He took ownership of complex parts of the product, delivered clean and reliable solutions, and helped turn ideas into a smooth experience for users. His attention to detail and technical skills made a real impact on the project. > - Majid Mohisen, Trellion AI ### Correctness: Making generated output behave like data A score is the most consequential thing this product produces, and a model will hand you one for an empty conversation if you let it. So generation is treated the way any other untrusted input is treated. Every call asks for a structured object, that object is validated against a schema before anything downstream sees it, and a response that does not fit is retried and then refused rather than stored. Stored results carry their provenance: the schema version, the prompt version, the model, and when it ran. That one decision answers a question that otherwise has no answer six months later, which is why two candidates interviewed a month apart can be compared at all, and why a prompt change does not silently rewrite what an old score meant. **The cache key is the contract.** Scoring is keyed by a digest of everything that could change the answer: the transcript, the questions the graph defined, the threshold, and the schema and prompt versions. Identical inputs never pay for a second generation, and bumping a prompt version invalidates every result derived from the old one without anyone remembering to clear a cache. The same discipline covers the parts that cost money. Usage is recorded against a key derived from what was generated, for whom, and at which step, so a retry after a network failure cannot count twice. Account state that lives with the payments provider is not something this system tries to own; a scheduled pass walks it and repairs any drift against the local projection. Callbacks are the fast path, the sweep is the guarantee, and the two agree because both write through the same step. On the read side the boring problem is the expensive one. A pipeline screen asks for candidates, their profiles, their roles, their latest interview and its score all at once, which is exactly the shape that turns one query into hundreds. Those lookups are collected within a single request, resolved as one query per relation, and handed back in the order they were asked for. The cache that makes it work lives and dies with the request, which is deliberate: a cache that outlived the request would be a way for one user's data to answer another user's question. ![Question by question interview analysis with highlights and concerns per answer]() ### Decisions: The arguments worth having Four product decisions did most of the work. Each had a faster option that would have shipped sooner and cost more later, and in each case the cost of my choice is the second thing I would raise in a review, not the last. | Challenge | Obvious option | What I did, and why | What it cost | | --- | --- | --- | --- | | Hiring teams cannot audit a black box | Generate the questions and show the results | Made the interview an editable graph the team owns, validated before it can be saved. A team that can read the questions can defend the outcome, and a product that can be read can be reviewed by people who are not engineers. | A canvas and a rule set to maintain, and a harder first-run experience than a list of questions would have been. | | Generated output drifting under the product | Call the model and render what comes back | Structured output validated against a versioned schema, stored with the prompt version that produced it, because an unversioned score is a number nobody can defend once the prompt has moved on. | Regeneration when a version changes, stricter prompts, and a retry path to maintain on every generated field. | | Callbacks with no ordering and no ids | Finalise on the last event that arrives | Derived a stable identity per event from its own contents, wrote each once under that identity, and finalised on the presence of the event that matters. Last is not a property the provider guarantees, so building on it would have been building on a coincidence. | A little state per conversation with a lifetime on it, and one more thing to reason about when an interview does not finalise. | | A third party that can fail mid-sync | Save locally and assume the provider followed | Snapshot before the push and restore that snapshot when the push fails, so the product and the agent can never describe two different interviews. | Two writes on the unhappy path, and a restore path that has to be tested because it almost never runs. | The candidate feedback path was the decision I expected to argue about and did not. Generating coaching notes for someone the company is about to reject costs real money and returns nothing measurable to the buyer. It stayed because the alternative is a product whose entire value to one of its two audiences is a rejection email, and because the transcript and the scoring pass that produce it already exist. One decision reads as process rather than architecture. The engineering standard for this codebase is written down and checked in beside the source: module layout, resolver and service boundaries, validation, batching, error mapping. It exists because the whole thing was built by one person over many months with AI assistance throughout, and the fastest way for that to produce an inconsistent codebase is to keep the conventions in one person's head. Every module also carries a short document describing its boundary and what it may not reach into, which is what makes a review of any single module possible without reading the rest. ### Lessons: What building an AI product taught me about the non-AI parts Almost none of the hard work was prompting. It was deciding what happens when the model is slow, wrong, or asked the same thing twice, and where in the system that is allowed to matter. Once generation sat behind a boundary that either returns a validated object or an error a person can read, the rest of the product stopped caring that it was talking to a model at all. The other lesson was about who owns the artefact. The moment the interview became something a hiring team could open, read and change, the conversations about it changed shape. They stopped being about whether to trust the system and started being about whether the third question was the right question, which is the argument worth having. **Where the time went: versioning what a model wrote.** Versioning generated output so old results stay meaningful. Making out-of-order callbacks land exactly once. Metering that never charges for a failure. None of it demos well, and all of it is why nobody has had to ask where a number came from. ## Wisp: A payment gateway where the shopper stays private and the merchant still gets paid https://www.zanastack.com/projects/wisp | Published 2026-09-12, updated 2026-10-15 A stablecoin checkout with two modes: public, which settles on-chain in one transaction, and private, which routes the payment through a zero-knowledge pool so nothing links the shopper to the shop. - Role: Co-founder (with a partner) · frontend lead, backend contributor - Timeline: 2026; phase one complete on testnet, not on mainnet yet - Team: Small team; the Solidity contract by another engineer - Stack: Next.js, TypeScript, Go, Postgres, gRPC ### Overview: Two ways to pay, one way to get paid Wisp is a stablecoin payment gateway. A shop drops in a checkout, a shopper pays in USDC from their own wallet, and the shop is paid without touching a wallet themselves. What makes it a product rather than a transfer button is the choice the shopper gets at checkout: pay in public, the way every on-chain payment works today, or pay privately, through a zero-knowledge pool that leaves nothing on-chain connecting them to the shop. The merchant side is a working product: a shop signs up, sees its balances, watches payments arrive, registers where private money should land, issues keys, and reads every notification its own server was sent. The shopper side is a checkout that takes a real payment on-chain. Phase one is complete on testnet; it is not on mainnet yet, so there are no real users or volume to report. What is still in front of us is settlement operations: payouts, reconciliation and disputes, and the case study says which is which rather than implying the whole thing is done. There are four moving parts: a Solidity router that settles public payments in a single transaction, a Go API that owns invoices and the ledger, a Node service that holds everything the privacy layer needs, and the web application that both the shopper and the merchant actually touch. I own the web application end to end, wrote parts of the API, and integrated both sides against the router. The contract itself was written by another engineer on the team. **The thing that makes it hard.** Privacy and compliance pull in opposite directions, and most attempts pick a side. A mixer gives you privacy that no bank will touch. A normal gateway gives you settlement that publishes the shopper's entire purchase history. The whole design problem is getting both: a payment an observer cannot trace, with evidence a partner can verify. - 4: Services on the payment path - 3: Independent idempotency layers before money moves - 0: Floating point numbers anywhere near money ![The payments list: every payment with its order, amount and network, filtered by status]() ### Problem: Every crypto payment publishes the buyer A blockchain broadcasts each purchase to anyone watching: the amount, who was paid, and the full history of the paying wallet. Pay a shop on-chain and you have handed that shop, and everyone else, your balance and your entire purchase history. No card network works this way, and no shopper would accept one that did. The existing fix makes the problem worse. Funds that pass through a mixer are routinely frozen by exchanges and off-ramps, so privacy bought that way is privacy a business cannot use. Meanwhile the merchant wants none of this: not volatility, not custody, not a wallet to look after. They want the money, in the form they chose, on time. ![A public payment in the merchant's dashboard, with the on-chain receipt anyone can open]() - **Privacy that a partner can verify.** A payment nobody can trace is only useful if we can still show a regulator or a banking partner where the money came from. Those two requirements have to hold at the same time. - **The shopper signs, so the shopper must be sure.** Everything a wallet is asked to approve comes from a server response. If a screen says one amount and the wallet signs another, the shopper loses their balance and finds out afterwards. - **Chains disagree about what happened.** A block can be reorganised out from under a payment that was already marked paid, and a shop may have been told about it. - **The privacy toolchain is in the wrong language.** The mature zero-knowledge pool ships a TypeScript SDK. The service that owns invoices and money is written in Go. ### Discovery: What the privacy layer turned out to be The first week of the private flow went into finding out what the pool actually requires, and three answers changed the plan. The first: the shopper's half of a private payment is much smaller than it looks. Moving money into the pool is a shield, and a shield needs no merkle tree, no synced wallet and no zero-knowledge proof. Proving is only required to spend from inside the pool, which is our side of the payment, not theirs. That collapsed the browser's job from a heavy client that downloads proving artifacts to one ordinary transaction, and it is the single biggest reason the checkout is fast on a phone. The second: the version of the pool we build on has no memo field on a shield. The obvious design, where the shopper attaches the invoice number to their payment, is not available. So the invoice is matched by what the server can observe on its own, and the shopper's client is never trusted to label money it is sending. The third: privacy pools do not exist on every chain, and the chains the gateway settles on are not automatically among them. That meant the checkout had to know the difference, rather than assume. **Where that left the browser.** It builds one note per recipient, sends the transaction, hands back the hash, and forgets the key it derived. It starts no engine, syncs no database, generates no proof, and implements no cryptography of its own: the hashing, the key exchange and the encryption are the SDK's. The SDK is loaded on demand, so a shopper paying in public never downloads any of it. ![Settings: where a merchant registers the private address payments arrive at, and the viewing key that can only read them]() ### System design: Four services, one story about money One API owns the money. A browser talks to it, That API owns invoices, the ledger and every decision about money; it never lets another service write a balance. Two things it cannot do itself live behind their own boundaries: the chain, which it watches rather than trusts, and the privacy layer, which speaks a language it does not. Solid lines are request paths. Dashed lines are things that arrive on their own schedule: blocks confirming, a scanner finding a payment, a notification going out to a shop. Note the top line: the shopper's wallet pays the contract directly, so the money never passes through us. A private payment leaves the same way, into the pool, and only the scanner sees where it landed. The browser never talks to the privacy service, and the privacy service never talks to the database. - Checkout: Shopper, no account - Merchant dashboard: Balances, payments, keys - Shop backend: Creates invoices, gets notified - Same-origin proxy: Token never crosses origins - Session + key auth: Merchant keys, invoice tokens - Go API: Invoices, quotes, ledger, payouts - Chain watcher: Events, confirmations, reorgs - Privacy sidecar: The only service holding the pool SDK - Router contract: Verifies, splits, forwards, once - Postgres: Double-entry ledger, one source of truth - Screening provider: Wallet risk, before private mode - Zero-knowledge pool: Public infrastructure - checkout to router: the shopper signs and sends - checkout to sameorigin - dash to sameorigin - shop to authz: server to server - sameorigin to authz - authz to api - api to db - api to screening - api to watcher - api to sidecar: typed contract - watcher to router: confirmed events - watcher to db - sidecar to pool: scan and decrypt - api to shop: signed notifications The router is the whole public payment. The API signs the exact terms of one invoice, the shopper's wallet calls the contract with that signature, and in one transaction the contract checks the signature, separates the fee, and forwards the rest to the merchant's own address. On the public path Wisp never holds the money: it never touches the transfer. A duplicate call on the same invoice reverts, so the contract, not our database, is the last word on whether a payment happened twice. The privacy sidecar is the answer to a language problem that could have gone badly. The mature pool SDK is TypeScript; the service that owns money is Go. Rewriting cryptography in Go to keep one language was never a real option, and neither was moving the ledger into Node. So the SDK lives alone in a small service that holds the pool wallet, scans for incoming payments and decrypts them, and offers the Go side a typed contract with five calls on it. That service has no database, no authority over balances, and no route in from the internet. The watcher is deliberately dull. It reads events from a block behind the head, records each one under the chain, transaction and log position it came from, and advances a stored marker so a restart resumes rather than replays. An event only counts once it has the confirmations the chain is configured to need, and an event whose block is later orphaned is marked as such rather than deleted, because the fact that we once believed it is part of the record. **A private payment, end to end** 1. **The shop creates an invoice.** A server-to-server call opens an invoice against the merchant's account and returns a link. The shopper is sent there; the link itself is the credential, so the checkout needs no account and no login. Fails when: An invoice that has expired, been paid, or been cancelled opens as a closed state rather than a payable one, so a stale link cannot take a second payment. 2. **The wallet is screened before the option exists.** Private mode is offered only to a wallet that passed screening, and the report is stored against the invoice. This ordering is the point: the entrance to the pool is controlled, so every private payment we later have to account for came from a wallet we checked first. Fails when: A wallet that does not pass is offered the public route instead, without an explanation to work against. A verdict that has not come back yet is not a pass: the public route carries on, and private mode waits for a real answer rather than treating silence as one. 3. **The shopper sees the whole price before anything is signed.** Amount, the rate behind it and the fee are on screen before a wallet prompt opens, with the quote counting down to its expiry rather than silently going stale. 4. **The browser checks the server before the wallet does.** The terms the wallet will be asked to approve arrive from the API, and are re-checked against exactly what the shopper was shown: the same amount, the same chain, a token from the list that chain published, and a contract from the deployment allowlist. Passed through is not the same as trusted. Fails when: A mismatch stops before any wallet prompt. A response that disagrees with the screen is treated as an attack, not as a new price. 5. **The payment is rehearsed before a wallet opens.** The exact call the wallet is about to make is simulated against the chain first. If the contract would reject it, the shopper is told that the shop's setup and the network disagree, before they have approved anything. Without that rehearsal the first thing they pay for is an approval, and the payment reverts immediately afterwards. Fails when: A rehearsal that cannot run, because the network is unreachable, does not block the payment: an unknown answer is not a refusal. 6. **The shield is one ordinary transaction.** The browser derives a one-time key from a signature, builds one note for the merchant and one for the fee, and sends them together in a single transaction addressed into the pool. The key is used and dropped: it is never stored, never sent to us, and cannot spend anything. Fails when: A rejected signature, a short balance, or a chain with no pool each end in their own message. A private invoice on a chain without a pool is refused outright rather than quietly settled in public, because a shopper who asked for privacy must never be given publicity instead. 7. **The pool hides what happens next.** From outside, a wallet deposited into a public privacy pool, which thousands of wallets do. Separately, a merchant was paid. No direct on-chain link joins the two. Amount and timing correlation are the residual risk, which is why the fee note and the merchant note travel in one transaction rather than two. Matching the shield to its invoice happens on our side from what the scanner sees, not from anything the shopper's client was trusted to attach. 8. **The hash is reported, and the report is not allowed to be the weak link.** By the time the browser tells us the shield happened, the money has already moved. So that one call retries with backoff rather than failing with the tab, and a refusal the server means (a bad request) stops immediately instead of hammering it. Fails when: A payment whose settlement depends on a browser tab surviving long enough to make an HTTP call is a payment that will sometimes not settle. That is written down as the open risk it is, not smoothed over. 9. **The merchant is paid and told.** Settlement is written to the ledger in one transaction with the fee, the shop is notified with a signed message it can verify, and the dashboard shows the payment with no receipt link, because there is no public record to link to. The absence is rendered as a statement rather than an empty cell. Fails when: A notification the shop does not acknowledge is retried with backoff and surfaced in the dashboard, so a shop is never silently out of sync about money it has received. Four stack decisions carry most of the weight here, and each one has a bill attached. Every one of these buys something specific and charges for it somewhere else. | Call | Why, concretely | What it costs | How that cost is paid | | --- | --- | --- | --- | | A separate service for the privacy layer | The pool's SDK is TypeScript and the money service is Go. A sidecar keeps each language where it is strongest, and confines the one dependency most likely to change to a service that can be redeployed on its own. | A second runtime, a network hop, and a contract to keep in step between two repositories. | The contract is generated from shared definitions both sides build against, so an incompatible change is caught before it merges rather than at runtime. The hop is internal only, and the sidecar owns no data, so the worst case is a feature pausing rather than money going wrong. | | Shield in the browser, prove on the server | A shield needs no proof, so the shopper's device does the cheap half and our infrastructure does the expensive half. On a phone that is the difference between a checkout that works and one that times out downloading proving artifacts. | The server carries the heavy cryptography and the pool wallet, which is now something we operate and secure. | The keys that matter live behind a secrets-manager interface rather than in code, and the sidecar is unreachable from outside. The browser never holds anything that can spend. | | The API signs the terms, the contract verifies them | The shopper's own wallet pays the merchant directly, so nobody has to trust us with the money in flight. A signature over the exact invoice terms is what lets the contract accept that payment without us standing in the middle of it. | A signing key that must be protected, and terms that cannot be edited after signing without invalidating everything. | The signer is one interface with one implementation to swap for a managed key service, and every field is re-checked in the browser against the screen before a wallet is asked to approve. | | One double-entry ledger, in integers | Balances are summed from entries rather than stored, so nothing can drift from its own history, and every amount is an integer in the currency's smallest unit. | More rows, and arithmetic that has to be written carefully rather than reached for. | The ledger is append-only where a correction would erase evidence, and the money helpers have their own runnable check, so a rounding mistake fails a command rather than a reconciliation. | ![The developer screen: API keys, the publishable token, the notification address and every delivery attempt]() > Zana turns designs into experiences. His frontend work isn't just functional, it's intentional. Every pixel has a reason. Rare to find that level of craft. > - Parsa Amini, Wisp ### Correctness: Everything that must not happen twice A payment gateway is mostly the study of things arriving more than once. A shop retries a request it never saw answered. A chain delivers the same event after a restart. A scanner replays a page it already read. Any of those crediting a payment twice is worse than an outage, because an outage is visible. So repetition is handled three times over, at three different boundaries. An inbound request carries a key that makes a retry return the first answer instead of doing the work again. A ledger movement is written under an identity derived from what it represents, so the same settlement cannot post twice. And a chain event is stored under the chain, transaction and log position it came from, which is a natural identity nothing else can collide with. Each layer would be enough on a good day; the point is that they do not share a failure. **Money never touches a float.** Every amount is an integer in the smallest unit of its currency, from the API response to the row in the ledger to the figure on the screen. Rates are integers too, scaled rather than rounded. The one place where arithmetic is unavoidable, splitting a payment into a fee and a remainder, has a runnable check that asserts the parts sum to the whole exactly. The chain gets the same treatment from the other side. Nothing counts as paid on its first sighting: an event waits for the confirmations that chain requires, and a block that is later orphaned marks its events as reorganised rather than deleting them, because a shop may already have been told and the record needs to show what we believed and when. The watcher keeps a marker per chain so a restart resumes from the last block it finished rather than rescanning from zero or, worse, skipping ahead. Notifications to a shop are their own small reliability problem. They are signed so the shop can verify the message is ours, retried with backoff when the shop's endpoint is down, and every attempt is recorded with its response, so a merchant can see in the dashboard that their own server rejected a payment notification five times rather than concluding that the payment never happened. The parts that are easy to get quietly wrong have checks that run as commands rather than opinions: the contract interface the frontend encodes against is asserted to match the deployed one, the money helpers are asserted to round the way the backend does, and the privacy layer's network support is asserted against the SDK's own list rather than a constant someone remembered to update. They run in seconds and fail loudly, which is what makes them useful in a place where a mistake is a wrong amount rather than a broken page. **The bug a test suite would never have found.** Registering a private address means pasting two long strings into a form. Browsers, trying to help, offered the merchant's saved login: one submit would have sent their own account password to the API as a viewing key. Autofill is off on those fields now, and they are labelled for what they are. The interesting failures in this product keep coming from the seams between a browser, a wallet and a server, not from the cryptography. ![A private payment: what happened, the fee breakdown, and a public record row that reads none, by design]() ### Decisions: Where the faster option was wrong Five product decisions took the most argument, and in each case the faster option was the one that would have been discovered later, by a shopper. | Challenge | Obvious option | What I did, and why | What it cost | | --- | --- | --- | --- | | A private invoice with nowhere private to go | Settle it in public and tell nobody | Refused the payment with an explanation. A shopper who chose privacy and silently received publicity has been harmed by the product, and no amount of conversion is worth it. The network list is read from the SDK's own configuration, so the day a pool appears on a chain we settle on, it starts working without a code change. | A checkout that sometimes says no, and a rule that has to be explained to a merchant. | | Server-signed terms the wallet must approve | Pass the signed parameters straight to the wallet | Re-checked every field against what the shopper was actually shown before any prompt opens, and pinned the contract to a deployment allowlist. A compromised or spoofed response would otherwise be able to show one amount and have a wallet sign another. | A second source of truth in the client, which has to be kept in step with the screen. | | A viewing key that reads every payment, forever | Take it in a form and store it like any field | Treated it as the sharp object it is: write-only at the API, encrypted at rest, never returned by any read, redacted from logs, and the form says in plain words what the key can do and to use a wallet kept for this purpose. The key that can spend never comes near us, which is why the merchant pastes an address they already own instead of being offered a wallet we would have to create. | A rougher onboarding step than generating a wallet for them would have been. | | A private flow that looked finished but moved no money | Leave it in and label it a demo | Deleted it. The flow faked the wallet signature, animated a proof that was never generated, invented a transaction hash, and told the shopper their payment was private. A screen that lies convincingly is worse than a missing feature, because everyone downstream plans around it. It came back weeks later on the real SDK, and what shipped in between was a refusal that told the truth. | A visible step backwards in a demo, and a feature that read as missing while it was being built properly. | | A private invoice could still settle in public | Treat the chain picker and the payment mode as separate choices | Closed the path instead. A private invoice is only offered networks that have a pool, is moved onto one if it was created elsewhere, and is refused outright when none of them can take it. The hole was not in the privacy layer at all: it was two correct screens that, used together, published exactly the payment someone had asked to keep private. | Fewer networks on offer for a private payment, and a rule the merchant has to be told about. | | The pool gives a shield no memo field | Have the client attach the invoice reference | Matched the payment from what our own scanner observes instead. A reference the paying client controls is a number an attacker can also choose, and on a payment gateway that is an invitation to credit someone else's invoice. | More work on our side, and a scanner that has to be watched, because now it is load-bearing. | One decision was about what not to build. The merchant application shipped without the screens the API could not yet serve, rather than with screens that invented their own contents. A balance nobody can stand behind is the fastest way to lose a partner's trust in everything else on the page, and each of those screens was cheap to add back the day the service behind it existed. The same instinct went through the copy with a red pen. A payout “expected within 2 to 6 hours”, a shield that takes “a couple of minutes”, a landing page promising five networks in under a minute: none of those numbers had been measured, and a payment product that guesses at timings in writing is teaching people not to believe it. They came out. What is left is what the system can actually stand behind. The same instinct shaped the checkout copy. The words the industry uses, the pool's name, the screening vocabulary, the compliance terms, appear nowhere in front of a shopper. They see an amount, a fee, a countdown, and a plain sentence about what is about to happen. The interesting machinery is the part a payment product should never make its user learn. ### Lessons: What building on a privacy protocol taught me The cryptography was never the hard part, because it was not mine to write. The hard part was deciding where uncertainty is allowed to live: which service may be wrong, which one is allowed to be slow, and which one is the last word when they disagree. Once the chain was something watched rather than trusted, the pool something reached through one boundary, and the ledger the only place a balance exists, most of the remaining questions answered themselves. The other lesson was about the boundary between a server and a wallet. In an ordinary product, the server is the authority and the client renders what it is told. When the client is holding a key that can move a stranger's money, that inverts: the client has to check the server, because the person clicking is the one who pays for a lie. Building the checkout as though the API might be hostile made it a better client of a friendly one. **Where the time went: the seams between services.** Not on the privacy layer. It went on the seams: what happens when a block is reorganised after a shop was told, what happens when the same event arrives twice by two routes, and how a browser can verify that the thing a wallet is about to sign is the thing a person just read. ## embertoast: A toast library you can call from anywhere, including outside React https://www.zanastack.com/projects/embertoast | Published 2026-09-14, updated 2026-09-20 An open source notification primitive: a framework-agnostic store with an imperative facade, a promise that morphs one toast in place, and an accessibility model that announces without ever taking focus. - Role: Author, open source - Timeline: 2026, published on npm - Team: Solo build - Stack: TypeScript, React, tsup, Vitest, Playwright - Source: https://github.com/zanasalimi/ember-toast - Package: https://www.npmjs.com/package/@embertoast/react ### Overview: One function, one renderer, no context between them embertoast is a toast library for React with one idea underneath it: the thing that produces a notification and the thing that draws it should not be the same thing. Calling code uses an ordinary function, from anywhere, including a module that has never heard of React. A single mounted component subscribes to a store and renders whatever is in it. That split is not a style preference. It decides what the library can do: whether an error handler in a fetch wrapper can raise a toast, whether a promise can morph through its own lifecycle without being replaced, and whether a renderer for another framework is a new subscriber or a rewrite. **What it ships as.** Two packages. A core with no dependencies and no React in it, holding the store and the imperative facade. A React package that subscribes, positions, animates and announces, with its stylesheet as a separate opt-in import so the headless path costs nothing. - 0: Runtime dependencies in either package - 4.5 kB: Size budget for the React package, enforced in CI - 65: Tests across the store and the renderer ![Four states of one card: work with no progress to report, an upload counting up, a promise morphing in place into its result, and a destructive question that waits to be answered]() ### Problem: A hook cannot be called from the places that need it most The usual shape for this is a hook. A hook can only run inside a component during render, which means every notification has to originate from a component and thread context or a ref down to whatever draws it. The places that most want to raise a notification are exactly the places a hook cannot reach: a request interceptor, a websocket handler, a retry helper, a plain utility three files from any component. The second problem is the promise. Nearly every library models a promise as three separate toasts, so a loading card disappears and a success card slides in behind it. The user sees a flit. What they should see is the same card changing its mind. - **Announcing is not the same as showing.** A toast that appears silently to a screen reader is not a notification. One that steals focus to be heard interrupts whatever the person was doing. - **Three animations, one element.** Three gestures want the same element at the same time, and run naively they overwrite each other. - **Right to left is not a stylesheet.** Most libraries leave mirroring to the consumer, so a right-aligned toast in an Arabic layout arrives on the wrong side of the screen and swipes the wrong way. - **Timers lie when nobody is watching.** A toast whose four seconds elapse in a backgrounded tab was never seen, and hover has to actually stop the clock rather than restart it. ### Discovery: What the split forces you to decide Once the store is a module-level singleton and the component is only a subscriber, a question appears immediately: where does a leave animation live? The store cannot own it. An exiting flag in the store would mean a dependency-free engine had to know about animation events, motion preferences and fallback timers, and worse, the snapshot would lie for a few hundred milliseconds about what is on screen. So the store removes synchronously and stays honest, and the renderer grows a small presence layer that diffs the live list against the previous one. An id that just left is held mounted, marked as exiting, and retired when the animation reports it finished, with a timer as a backstop because a backgrounded tab may never fire that event at all. Re-adding the same id cancels an exit in flight. Under reduced motion the whole phase is skipped. The other discovery was about directions. Writing positions as screen edges means writing mirroring twice. Writing them as the inline axis and putting the box geometry in logical properties means right to left is the same code path, and the only work left is reading the document direction and watching it for changes at runtime. ![An action toast answered with a click, then the next one swiped off the screen]() ### System design: A store that knows nothing about the screen Nothing to the left of the store knows what a pixel is. Calling code talks to a facade, the facade talks to a store, and the store notifies subscribers. Everything to the right of the store is rendering, and everything to the left of it is a function call that could have come from anywhere. Solid lines are calls. The dashed line is the subscription: the renderer is told that something changed and reads the new snapshot itself, which is what keeps the store free of React and makes a renderer for another framework a new subscriber rather than a rewrite. The dashed line back to the store is a toast item reporting a pause on hover, a resume on leave, or a dismiss on click. - A component: The ordinary case - Any other module: Interceptor, worker, utility - toast(): success, error, loading, promise, custom - Store: Ids, timers, pause, queue, pub/sub - Subscription hook: Concurrent-safe external store read - Toaster: Positions, stacking, reflow, live region - Presence layer: Holds a dismissed toast for its exit - Toast item: Countdown, swipe, role, exit - Live region: Announces, never takes focus - component to facade - module to facade - facade to store - store to hook: notify - hook to toaster - toaster to presence - presence to item - toaster to item: reflow - toaster to live - item to store: pause, resume, dismiss The promise method is the only part of the facade with real logic. It opens one toast pinned open, keeps the id, awaits the promise, and on settle updates that same id with the new type and content. One id, one node, from loading straight through to success. The morph is the whole point: nothing is replaced, so nothing flits. Reflow is the hot path, because when a toast leaves, the survivors have to move and they have to do it at frame rate. The renderer measures where each card is, lets layout settle, measures again, applies the inverting transform and then releases it, so only transform and opacity ever animate and the browser never lays out per frame. The collapsed deck uses scale, the swipe uses its own transform, and the reflow uses translate, which is why three gestures can share one element without one cancelling another. Timers track elapsed time rather than being recreated, so a hover that pauses at 2.3 seconds resumes with 1.7 left rather than starting over. They also pause when the tab is hidden or the window loses focus, because four seconds that elapse while nobody is looking were never a notification. The four calls that shaped the library. Every one buys something specific and charges for it somewhere else. | Call | Why, concretely | What it costs | How that cost is paid | | --- | --- | --- | --- | | A module-level store rather than a hook or context | A notification is raised from wherever the thing went wrong, which is usually not a component. A singleton store makes the producer independent of React entirely, and lets many modules feed one renderer. | A singleton is global state, and server rendering has to be given a snapshot rather than allowed to touch it. | The React bridge is the framework's own external-store API, which is concurrent-safe and has a server snapshot, so there is no tearing and no window object on the server. | | Synchronous removal, with the exit animation in the renderer | The store should describe what is live. A dismissed toast that lingers in the snapshot for the length of an animation makes every consumer of that snapshot slightly wrong. | The renderer needs its own presence bookkeeping, and a ghost could in principle be stranded if the animation event never arrives. | The presence layer retires on the animation event with a timer as a backstop, and skips the phase entirely under reduced motion, so a hidden tab cannot leave a card behind. | | Positions named by the inline axis, geometry in logical properties | Right to left becomes the same code path as left to right instead of a second stylesheet the consumer maintains, and the swipe axis mirrors with it. | The names are less obvious to someone thinking in screen edges, and the direction has to be resolved and watched at runtime. | The default reads the document direction and observes changes, so a language switch mid-session mirrors without a remount, and the styled layer stays optional. | | CSS with custom properties rather than a style runtime | Zero dependencies, nothing to execute per toast, and a consumer can retheme without learning an API. It also keeps the package tree-shakeable. | No dynamic styling escape hatch, and consumers who want radical changes write their own markup. | The headless path is first class: the store and the facade carry no styling at all, and the stylesheet is a separate import that a headless user never loads. | ### Correctness: The parts that are easy to get quietly wrong Accessibility is treated as a feature rather than a pass at the end. Each renderer owns one live region, and toasts are inserted into it so assistive technology announces them without the visual container deciding announcement order. Politeness follows severity: errors and warnings interrupt, everything else waits its turn. Focus is never moved, because a notification that takes the cursor out of a form is a bug wearing a feature's clothes, and an interactive toast is reachable by keyboard without trapping anyone in it. Motion is a preference, not a default. Under reduced motion the enter, exit and reflow transitions collapse to nothing, and the presence layer skips its exit phase rather than waiting for an animation that will never play. The library is fully usable with every animation off, which is the actual test of whether motion was decoration or structure. **The size budget is a test, not a note in the README.** Both packages declare a byte budget and CI fails when it is exceeded, so a convenience dependency cannot be added without someone deciding it is worth the bytes. The core stays free of side effects and free of dependencies, and the stylesheet is separately importable so the headless path is genuinely smaller rather than nominally smaller. The tests split along the same seam as the code. The store's tests are plain unit tests with no DOM: ids, queue promotion, pause and resume arithmetic, the promise lifecycle. The renderer's tests cover what only exists once there is a document: the live region and its politeness, the mirroring, presence and reflow behaviour, and the swipe. The gesture is the one thing a unit test cannot honestly claim, so it is verified in real browsers, including mobile Safari, where pointer events behave least like the others. There is a small discipline in the overflow rule that is easy to miss. Only a fixed number of toasts render per position and the rest queue, so a burst of twenty cannot bury the page, and promotion is first in first out so the order is something you can reason about rather than a race. ### Decisions: What I deliberately did not build Most of the restraint in this library is about what the core is allowed to know. It does not know what a pixel is, whether a document exists, or that animations have durations. Every time something wanted to leak into it, that was the signal it belonged in the renderer instead, and the presence layer exists because of exactly one such attempt. I also left out the temptation to ship adapters for other frameworks. The store makes them possible, which is the part worth claiming; shipping renderers I would not maintain would be a worse promise than not shipping them at all. **Documentation as a consumer.** The docs site builds against the packages from source rather than the published build, so the examples are exercised by the same code a user installs, and a broken API shows up while writing the page about it. ### Lessons: What a small library teaches about boundaries The useful lesson is how much a single boundary decides. Splitting the producer from the renderer answered questions I had not asked yet: where exit animations live, why the promise can morph, how another framework would attach, and what the core is allowed to import. When a design keeps answering later questions without being revisited, that is usually a sign the seam was cut in the right place. The other lesson is that the unglamorous requirements are the ones that make a component library trustworthy. Announcing correctly, honouring reduced motion, pausing a timer when the tab is hidden and mirroring properly are not features anyone lists when they pick a toast library, and they are exactly what someone notices when they are missing. ## sift: An AI research agent that grades its own calls three days later https://www.zanastack.com/projects/sift | Published 2026-09-14, updated 2026-09-20 Every analysis is stored with the price at the time. Three days on, a scheduled job fetches the real price and marks the call right or wrong. The number that comes out is public, including the parts that are unflattering. - Role: Author, open source - Timeline: 2026 - Team: Solo, built with coding agents under review gates - Stack: Next.js, TypeScript, Postgres, scheduled jobs - Source: https://github.com/zanasalimi/sift ### Overview: The analysis is the easy half Asking a language model what it thinks of a token is easy and nearly free. The answer arrives in confident prose, sounds plausible, and nobody ever goes back to check it. That describes most generated market commentary: a great deal of output and no accountability attached to any of it. sift produces that analysis too, through a five stage pipeline, with the model chosen per role at each stage that uses one. The part worth building is what happens afterwards. Every call is written down in a form specific enough to be judged, and three days later a scheduled job asks what the price actually did and records whether the call was right. **The engineering problem, stated plainly.** Not the prose. The feedback loop: storing a claim precisely enough that a machine can grade it without a human interpreting anything, waiting out the horizon, and scoring it against reality under rules written before any result existed. - 3 days: Between a call and its verdict - 268: Unit tests, plus browser smoke tests on the real build - $0.003: Typical cost of one analysis, by the token prices it records ![A finished analysis: where it traded, the catalysts, the risks, and the headlines behind them]() ### Problem: Confident prose that nobody can grade A model will produce a market view for any asset you name, at any length, in a voice that sounds like it knows. There is no natural moment where that view meets a result. By the time the price has moved, the conversation is gone, the claim was vague enough to fit either outcome, and the next question starts from zero. Making that loop exist turns out to constrain almost every other decision in the system. A claim has to come out of the model in a shape a job can compare against a number, which rules out free prose. It has to carry the price it was made at, which means the market call and the storage cannot be separate concerns. And the rule that decides right or wrong has to be fixed in advance, because a rule chosen after seeing results is not a measurement, it is a story. - **Neutral is the loophole.** Without a dead zone, a neutral call can never be wrong: any real move is up or down, and either reads as roughly flat if you want it to. - **A model will invent precision.** Asked for support and resistance from seven daily closes, it produced round numbers near the current price, and once a support level identical to the price itself. - **Free tiers meter by the day.** The strongest model on the free tier allows twenty requests a day, which makes model choice an operational question rather than a constant in a file. - **The inputs are written by strangers.** Headlines arrive from public feeds. Getting a sentence into one does not require touching this system at all. ### Discovery: What the free data actually supports The plan assumed a per-coin news endpoint. Testing showed the category filter on the provider I had chosen returns general crypto news whatever category you ask for, and no free source covers assets outside the majors. Pretending otherwise would have meant an analysis quietly built on headlines about something else. So coverage became a value the system carries rather than an assumption it makes. Headlines that actually name the asset are one tier, market-wide context is another, nothing is a third. The prompt is told which tier it is working in and told to cap its confidence in the weaker two, and the card tells the reader the same thing. The honest version is better than the complete-looking one. The second finding came from the price series. The first version asked the model for levels and gave it seven daily closes, which cannot locate a level, so it made them up. The same response already carried a week of hourly points that were being thrown away. Those get reduced by a plain function to the bands where the price actually spent its time, and a band has to hold well over its even share of the week to be named at all, so a week that drifted evenly reports nothing rather than inventing concentration that was not there. ### System design: Five stages, and only two of them can end the run Follow one question through. A message becomes an intent, an intent becomes a pipeline run, and the run writes one row that the evaluation job will come back for. Dependencies point one way: providers talk to the outside world, the pipeline orchestrates and never imports the interface, and the routes are thin. Solid lines are the request path. Dashed lines run on their own clock: the scheduled job that judges old calls, and the verdict it writes back against each one. Only market data and synthesis can end a run; news, sentiment and extraction may each fail and the analysis continues with less evidence, saying so. - Chat message: Top hundred coins by market cap - Intent parse: Which coin, or a refusal - 1. Market data: Fatal if missing - 2. News: Coverage tier is a value - 3. Sentiment: Degrades to a fallback - 4. Extraction: Fifteen headlines to five facts - 5. Synthesis: Schema-constrained, fatal if it fails - Model registry: Roles, not model names - Price and news feeds: Cached per stage - Model providers: Cheap ones do the bulk work - Postgres: The call, and the price it was made at - Daily job: Finds calls old enough to judge - Verdict: Direction, against the real move - Track record: Public, including the misses - chat to intent - intent to market - market to news - news to sentiment - news to extract - sentiment to synth - extract to synth - market to providers - news to providers - registry to models: picks by role and allowance - synth to db: one row, with the price - db to cron: three days later - cron to verdict - verdict to record Every stage that matters returns a structured object validated against a schema rather than prose that something later has to parse. This is not about developer convenience. It is the thing that makes the evaluation possible at all: a trend has to be one of three values for a job to compare it against a price move, and a parser over model output would be a second thing that can be wrong. When generation fails the schema check it retries once and then returns a typed error, because retrying three times on a metered allowance spends the requests that would have served real users. Models are chosen by role rather than by name. Intent, extraction, sentiment and synthesis each have an ordered list, each model carries a daily request limit, and usage is counted from what the provider actually reported rather than what was hoped. When one is spent the next takes over and the switch is recorded on the analysis, so a fallback run cannot quietly pass itself off as a normal one. Cost stays near a third of a cent per analysis through three habits. The cheap model reads fifteen headlines and hands the expensive one five bullets, so the expensive prompt is small. Reasoning is turned down to the floor the model allows, because on this task most of the budget went to thinking rather than answering. And repeat work is cached per stage on short windows, so a second question about the same coin inside the window makes no external calls at all. ### The feedback loop: Three days later, the price settles it This is the part the rest of the system exists to serve. When an analysis is written it stores the direction it called, the confidence it claimed, and the price at that moment. Nothing else about it is opinion: those three fields are enough for a machine to decide later whether it was right, with no human reading the prose and deciding what it meant. Three days on, a scheduled job wakes up, finds every call that has reached its horizon, and asks the price feed what actually happened. Bullish is correct if the price rose past the same threshold a neutral call has to stay inside, bearish if it fell past it, and a move that stays inside the band scores only the neutral call correct. The three verdicts cannot both be right about one move, and none of them is graded on magnitude, because sift called a direction and scoring it on size would grade a claim it never made. **Neutral is the rule that makes the rest honest.** A neutral call is only correct if the move stayed inside a threshold, two percent by default. Without that dead zone neutral could never be wrong: every real price move is either up or down, and one of those is always readable as flat enough. The threshold is what turns neutral into a claim that can fail, and an analyst who cannot be wrong is not keeping score. **One call, from claim to verdict** 1. **The claim is stored in a gradeable shape.** A direction from a fixed set of three, a confidence number, and the price at the time of the analysis. The horizon and the neutral threshold in force are written onto the row too, not read from configuration at judging time. Fails when: If synthesis cannot produce an object that satisfies the schema, there is no analysis and nothing is stored. A half-formed claim is worse than none, because it would be graded as though it meant something. 2. **The row waits out its horizon.** Three days by default, configurable, and stored per evaluation so changing it later cannot silently rewrite what older calls were judged against. 3. **A daily job collects what is due.** It reads the calls that have reached the horizon, then asks for one price per coin rather than one per call, so a backlog of thirty calls on the same asset is a single question to the feed. Failures are cached for the run too; otherwise one unreachable coin costs a slow request per row. Fails when: The endpoint refuses to run at all when its secret is unset, rather than defaulting to open. A scoring job anyone can trigger is a scoring job anyone can pollute. 4. **The verdict is computed by rules fixed in advance.** Percentage change from the stored price, then direction compared against it, with the neutral threshold from that row. The rule lives in one small module on its own so it can be read, tested and, most importantly, not quietly adjusted after a bad week. Fails when: If the stored price is missing or zero, the row is left unscored rather than recorded as flat. Returning zero would have avoided a division and invented a verdict the system never earned. 5. **A missed call is left unscored rather than guessed.** If the price lookup fails, the row stays due and the next day's run picks it up. Nothing in the loop is allowed to write a number it is not sure of, because the whole value of the number is that it was not chosen to flatter anyone. 6. **Wrong calls get a post-mortem, and it is never read back.** The cheapest capable model writes two or three sentences on what the reasoning leaned on that did not hold, and that note is stored. It is deliberately never fed into a later analysis. Fails when: A post-mortem that cannot be written does not fail the evaluation. The verdict is the thing that has to land; the note is commentary on it. That last decision is the one people ask about. Feeding the lessons back in is the obvious next feature and it is exactly what would break the measurement: the accuracy figure would stop describing one system and start describing a moving one, and this month would no longer be comparable with last. It becomes worth doing at a few tens of scored calls, and only with the lesson sets versioned so a track record can say which version produced it. The public page is deliberately unflattering about its own sample. It reports directional accuracy over a window, a breakdown by coin and by trend, and the confidence gap between calls that were right and calls that were wrong. With a handful of scored calls it says that this is a working loop with almost nothing in it yet, rather than showing a triumphant number that is really one lucky guess. Directional accuracy starts meaning something in the tens, and calibration, where calls made at eighty percent confidence should be right about eighty percent of the time, needs a few hundred. ![One analysis open: the stages it ran, the model behind each one and what the run cost]() ![The track record: every past call scored against what the price actually did]() ### Decisions: The calls that were not obvious Four decisions did most of the work, and in each case the easier option would have produced a system that looked the same and meant less. | Challenge | Obvious option | What I did, and why | What it cost | | --- | --- | --- | --- | | Making a claim gradeable | Let the model write its view in prose | Constrained every stage that matters to a schema, so the direction is one of three values and a job can compare it to a number without interpreting anything. Prose would have needed a parser, and a parser over model output is a second thing that can be wrong. | Stricter prompts, a retry path, and stages that fail loudly rather than degrading into something almost usable. | | Neutral calls that could never be wrong | Score direction only, and let neutral pass | Gave neutral a dead zone it has to stay inside, and stored the threshold on each evaluation. A claim that cannot fail is not a claim, and a threshold changed later must not rewrite what older calls were judged against. | One more number to defend, and a rule that has to be chosen before it is convenient to know the answer. | | A metered free tier on the good model | Put the model name in a constant and hope | Made the pipeline ask for a role rather than a model, counted usage from what the provider reported, and recorded the switch on any run that fell back. A silent fallback would make two different systems share one accuracy number. | A registry to maintain, and limits written down that the provider can change tomorrow, so they are overridable without a deploy. | | Headlines written by strangers reaching a model | Sanitise the text and move on | Kept rules and data in separate channels so untrusted text has no way to present itself as an instruction, sanitised at ingestion, and ran a crafted malicious headline through the real pipeline as a test that asserts the call does not move. | Injection is mitigated rather than solved, and the honest limit is stated rather than claimed away. | What makes that last one tolerable is that there is nothing to escalate to. No model call in sift defines a tool, nothing a model returns triggers a fetch or a write, and a successful injection buys exactly one wrong analysis, which gets scored three days later and shows up in the track record as a miss. There is also a decision about conversation. History is never sent to a model: each analysis stands alone, and the only thing carried between messages is the last coin discussed, which is what makes a follow-up like asking for the risks resolve to the same asset. The cost is real, sift cannot answer a question about its own previous answer, and the interface says so under the composer rather than letting somebody discover it. ### Lessons: What building a scoreboard teaches about building the thing it scores Deciding up front that the output had to be gradeable changed the design more than any model choice did. It is what forced schemas instead of prose, one stored price per call, a fixed rule in its own module, and a refusal to write a verdict that could not be computed. A system that has to prove itself later is built differently from one that only has to sound right now. The second lesson is that honesty is mostly a set of small refusals: leaving a row unscored rather than guessing, reporting a coverage tier rather than implying the headlines were about this asset, naming no price bands when the week had none, and publishing a sample size that undercuts the number next to it. None of those are hard to build. They are just easy to skip, and skipping any one of them would make the headline figure meaningless while leaving it looking exactly the same. ## Cofield: An infinite canvas where the server is not allowed to decide who wins https://www.zanastack.com/projects/cofield | Published 2026-09-14, updated 2026-09-20 A collaborative whiteboard built on a CRDT instead of a central authority: several people draw on one board at once, keep drawing with no connection, and their histories merge back without anyone arbitrating. - Role: Author, open source - Timeline: 2026 - Team: Solo build - Stack: Next.js, TypeScript, Yjs, Node ws, LevelDB - Source: https://github.com/zanasalimi/cofield ### Overview: One board, several people, no arbiter Cofield is an infinite canvas that several people edit at the same time. The board is a replicated document: every connected client holds a full copy, edits apply locally first, and the copies converge on their own. The server relays and stores. It never decides whose change was legal, because it is never asked. That choice is what makes the interesting cases work. Two people dragging the same shape at the same moment, someone editing with no connection for ten minutes, a browser refreshed mid-session: none of them need a human to resolve anything afterwards. ![A comment pinned to a point on the board, with its author, the thread it opens and a resolve control]() **Why there is no hosted demo.** The thing worth showing needs two signed-in sessions on one board, and the system is a web app plus a sync server holding long-lived sockets and a durable store. Rather than a single-player demo that proves nothing, it runs locally in two commands and two tabs, and the recording above is a real two-person session. - 0: Operations the server transforms to resolve a conflict - 63: Deterministic tests, no DOM and no network - 2: Processes: the app, and the relay that runs without it ### Problem: Two people, one shape, the same moment Under last write wins, one of those two edits disappears and neither person is told. That is the cheapest thing to build and the worst thing to experience, because the failure is silent and the person who lost work finds out much later, if ever. Operational transform avoids the loss by having a server rewrite every operation against every concurrent one. It works, it is famously hard to implement correctly, and it makes that server the thing every client depends on for permission to proceed. Then put one of those people on a train with no signal for ten minutes and the question stops being about latency: it is about how two histories that genuinely diverged become one again without somebody choosing. - **Offline is not a queue of intentions.** Replaying stored intentions on reconnect means deciding what a move meant when the thing it moved has since changed, which is where conflict prompts come from. - **Cursors are not document data.** Presence is written at pointer frequency and worthless a second later. Stored in the document it would grow it without bound and replay stale cursors on the next load. - **Without an authority, permissions get harder.** A server that never inspects an operation cannot refuse one for being illegitimate, so read-only has to be enforced some other way. - **Undo is per person, not per board.** In a shared document the naive undo stack reverts whatever happened last, which is regularly somebody else's work. ### Discovery: What the data structure gives you for free Four things in this codebase depend on Yjs specifically, and finding that out is what settled the choice. Its updates are binary diffs exchanged against a state vector, so a client returning after ten minutes away sends and receives only what the other side is missing rather than a whole board. Its nested types let each shape be a map with one key per field, which is the difference between a merge and a loss. That second one is the detail that matters most. If a shape were one value, two people editing different parts of it concurrently would clobber each other: one drags it while the other recolours it, and one of those edits is gone. As a map with a key per field, each field merges independently, and the shape converges to moved and recoloured. The other two came for free with it. The presence protocol ships alongside and rides the same socket, so cursors needed no second transport. And the undo manager filters by where a change came from, so local edits are tracked and updates arriving from the network are not, which gives per-person undo without any bookkeeping of my own. **The sharp edge, stated.** The library refuses to run with two copies of itself in one process, which is a real failure mode in a monorepo where a server and a client both want it. The sync server therefore imports only the copy bundled inside its websocket dependency rather than adding its own, and that is written down next to the import rather than left as folklore. ### System design: A relay, two caches, and a document that lives everywhere The server is the smallest box on this map, on purpose. Each browser holds the whole document and its own offline cache. The server in the middle joins sockets to rooms, relays what it receives, and writes the document to disk. Presence travels the same socket and stops there. Solid lines carry the document and end up on disk. Dashed lines carry presence, which is broadcast and then forgotten. The server appears once, in the middle, and nothing in the picture asks it to decide anything: it never transforms an update; the only frames it inspects are write frames from a read-only member, which it drops. - Canvas: Optimistic, repaints on the same frame - Document replica: Shapes, order, board meta - Presence: Cursor, selection, viewport - Offline cache: Same document, in the browser - Room join check: Session and board membership - Relay: Sync protocol, no transformation - Read-only filter: Drops write frames from viewers - Durable store: Rehydrates a room after a restart - Document replica: Merges by the same rules - Presence: Times out when they leave - uiA to docA - docA to idbA: local durability - docA to auth - auth to readonly: write frames - readonly to relay - relay to level - relay to docB: update diff - awareA to relay: presence - relay to awareB An edit takes the local path first. A drag writes to the local replica and the renderer repaints on the same frame, so the network round trip is nowhere near the interaction. The diff goes to the relay, which applies it to the room copy, writes it, and broadcasts it. The other browser merges by the same rules the first one used, which is why message order and concurrency do not change where both end up. Offline is the same path with the socket missing. Edits made while disconnected go into the ordinary document, not a queue of pending intentions, and the browser cache holds them. On reconnect the two sides exchange state vectors describing what each already has and send only the difference, in both directions. There is no replay step and no conflict prompt, because the merge order is a property of the data rather than of who reconnected first. Presence is deliberately on its own channel and never persisted. Cursor updates are throttled and receivers interpolate between samples, so motion stays smooth without flooding the socket, and a disconnected person's cursor disappears on a timeout instead of lingering as a ghost. The cost is that presence has no history: there is no record of who was looking at what, and nothing survives a refresh. **An edit made with no connection** 1. **The edit applies locally.** The shape changes in the local replica and the canvas repaints on the same frame. Nothing waits for a socket, so the interaction feels the same offline as online. 2. **The browser cache takes it.** The replica is persisted in the browser, so the work survives a refresh, a crash or a closed laptop while still disconnected. Fails when: Site data blocked or a private window: the board degrades to memory only and says so, rather than implying a durability it does not have. 3. **The socket comes back.** The client reconnects to the relay and rejoins the room, which re-checks the session and the board membership before any frame is exchanged. Fails when: Membership revoked while away: the join is refused and the local copy stays local. 4. **Both sides say what they already have.** Each end sends a state vector describing its own version, and only the difference travels, in both directions. A ten-minute gap costs one exchange, not a replay of ten minutes. 5. **The merge needs nobody.** Both replicas apply the difference under the same rules and land on the same document. There is no conflict prompt, because the order edits merge in is a property of the data rather than of who reconnected first. The four calls that define the system. Each buys something specific and charges for it somewhere else. | Call | Why, concretely | What it costs | How that cost is paid | | --- | --- | --- | --- | | A CRDT rather than operational transform or locking | Conflict resolution becomes a property of the data structure, so it happens identically on every client with no coordination. That is what makes offline editing possible at all, and no client waits on a server for permission to proceed. | Deletes leave tombstones, because the document has to remember a removal to converge with a peer that never heard about it, so a heavily edited board grows. | The growth is named rather than hidden: snapshotting and garbage collection are the known next step, and the trade-off that collection complicates very late offline edits is the reason it is not in yet. | | A server that relays and never transforms | The failure mode of an authority is that everyone stops when it disagrees or disappears. A relay can be restarted, and clients keep working through it. | Permissions cannot be enforced by validating intent, which is the tool an authority would reach for first. | A viewer is held read-only at the transport: frames are decoded and the ones that would write to the shared document are dropped before they reach it, which is blunter than an authority but does not require one. | | Each shape as a map with a key per field | Two people editing different fields of the same shape merge instead of one losing. Moved and recoloured is the correct answer, and it falls out of the data model rather than out of a rule someone wrote. | More structure per shape than a plain object, and every field access goes through the document rather than a JavaScript value. | The document module wraps that in typed helpers, and the merge behaviour it exists for is pinned by tests that run two clients against each other with no browser. | | Presence on its own channel, never in the document | Cursors are written constantly and are worthless a moment later. Keeping them out of the document keeps it from growing without bound and keeps stale cursors from replaying on the next load. | No presence history at all, and nothing survives a refresh. | A test asserts the boundary holds, because writing a cursor into the document is a one-line mistake that would not show up until the board had been open for an hour. | ### Correctness: Proving convergence without a browser The parts most likely to be wrong are the ones hardest to check by eye, so they are the ones written as pure functions and tested without a DOM or a network. Two clients editing concurrently and ending up identical is a unit test. Offline edits merging in both directions on reconnect is a unit test. Hit testing, marquee intersection, resize and rotate transforms, and the world to screen conversion are all pure geometry, tested the same way. That choice pays twice. It makes the suite deterministic, which means a convergence failure is reproducible rather than a flake, and it keeps the collaboration logic honest: anything that needs a browser to prove is a thing whose behaviour depends on the browser, which for a merge rule would be a bug in itself. **The cache is allowed to fail.** The offline cache is the one part that can genuinely be unavailable: a private window, or a browser set to block site data. When it is, the board still syncs over the socket, so the app degrades to memory only and says so rather than quietly dropping a durability guarantee the person thinks they have. The paint loop is the other place where correctness and performance meet. All geometry is computed in world coordinates and the viewport applies a single transform at render time, so selection and resize maths stay right at any zoom and a pan is a transform change rather than a data change. Each frame draws only the shapes whose bounds intersect the visible rectangle, so a board with a thousand shapes costs what is on screen rather than what exists, and small changes repaint their own rectangle instead of the whole canvas. Because culling and the world to screen split already exist behind a renderer interface, moving to a different drawing backend for much larger boards is a new implementation of that interface rather than a rewrite of the tools and geometry above it. ### Decisions: What is deliberately missing The clearest decision is the one that shows up as an absence: there is no hosted demo, for the reason given above. A public multiplayer board without the team layer built would also be a different product, with different problems. The second is admitting where the model has a bill. Shipping garbage collection carelessly would trade the system's best property, a ten-minute offline edit that still merges, for a smaller file. The access model is the third. Boards have members with roles, and the role is enforced where the socket joins rather than trusted to the interface, so a viewer is read-only at the transport rather than by being asked nicely. Organisations and teams are designed and not yet built, and the document says which is which. ### Lessons: What a CRDT moves rather than removes The lesson I did not expect is how much a CRDT relocates rather than solves. Merge conflicts genuinely disappear: two people cannot lose each other's work, and no code of mine decides who wins. What arrives in their place is a set of quieter obligations, tombstone growth, permissions that cannot be enforced by inspecting intent, and presence that has to be kept out of the document by discipline rather than by the type system. The second lesson is that the right seam showed up early and kept paying. Keeping all geometry in world coordinates, keeping presence off the document, and keeping the server out of the decision made offline editing, per-person undo and viewport culling each a small piece of work rather than a project. Most of the hard thinking happened once, in choosing what each layer was allowed to know.