← Anton Mendelson

Ardoca: rebuilding a live product

Immigration firms collect passports, bank statements and signed forms from every client. I joined a small company that already had firms on its product and rebuilt the platform: design, client, workflow engine, storage, AI and operations.

When
May 2026 to now
Users
20 immigration and relocation firms
My part
Product design, frontend, backend, infrastructure, data protection
Stack
React 19, TypeScript, Vite, Tailwind, TipTap, PDF.js · Go, Chi, MySQL, sqlc, pdfcpu · Cloudflare R2, Hetzner, Dokploy, GitHub Actions · Mistral (EU)
Code
466 commits in the new repository since July 2026, all mine

Ardoca is a platform for immigration and relocation firms. A firm builds a document workflow once and assigns it to a client. The client fills in forms, uploads files and signs PDFs in a portal with the firm's branding. Staff review what arrives, ask for a better copy when a scan is unreadable, and prepare the application.

The company started in 2025 as ezyClients, with a Nuxt frontend, a Go backend and firms already using it. I joined in May 2026. In July I started the rebuild in a new repository. Since the end of August it has run in production at app.ardoca.com, and 20 firms work in it today. I design the screens and write the code, and I run the servers.

What I rebuilt

  • The client, from scratch. React 19, TypeScript, Vite and Tailwind replaced the Nuxt app. Every route kept its exact path and query parameters, because the backend had already emailed absolute links for account activation and client invites, and those had to keep working.
  • The workflow builder. One scrolling column of step cards became three panes: the steps on the left, the selected step in the middle, the selected block's settings on the right. Content is edited at the width the client will read it. A firm's template and one client's own copy open in the same builder.
  • Client data. On the old platform, a lawyer's correction to a filled PDF was overwritten on reload. Now each client has one data profile keyed by named fields such as personal.first_name. Forms write into it, PDFs read from it when they are generated, and a correction goes into the profile, where the next generated PDF picks it up.
  • PDFs and signatures on the server. The firm uploads the real form, places text, dates, checkmarks and signature boxes on it, and the Go backend stamps the client's values and drawn signatures in with pdfcpu. The browser no longer composes any PDF.
  • One host for every firm. Each firm gets its own subdomain at signup with nothing provisioned per firm, so a new firm's branded login page works on the first request.

A decision I reversed after six days

My first architecture decision kept the old content engines, SurveyJS for forms and a separate format for PDF fields, so the rebuild would need no data migration at all. Six days later I replaced it. Keeping them meant running two form systems with two review pipelines for as long as old cases stayed open, which could be months, and paying for the SurveyJS licence the whole time.

Instead I wrote one-time converters: SurveyJS forms to the new form schema, PDF field layouts to placements, old keywords to named fields, TipTap 2 documents to TipTap 3, and files on the server's disk to Cloudflare R2. The old platform's data has been moved over with them. The original data stays in legacy columns as a backup, and anything a converter cannot map is flagged. Files now go from the browser straight to an EU bucket through a presigned URL with a SHA-256 checksum, and the backend confirms them afterwards.

AI on identity documents

The AI features read the most sensitive files a firm holds: passports, bank statements, tax papers. So the first question was where those files go, and the answer is written down as an architecture record.

  • One processor, Mistral, through its EU endpoint only. The backend refuses any other endpoint outside local development, and model ids are pinned, because "latest" aliases can change the model without notice.
  • A switch per firm, off by default. The code reads it on every call, so turning it off stops the very next document from leaving.
  • Every AI value is a suggestion that a person confirms. Nothing is approved, rejected or submitted automatically.
  • File names are never sent, and nothing extracted is logged. Zero data retention at Mistral has been active since 7 October 2026.

Firms that use their own AI tools can connect them to Ardoca through a gateway API and an MCP server. Their tool reads the firm's submitted documents with a key the firm can rotate or revoke, and Ardoca itself sends nothing to an AI provider on that path.

A deleted case in June

On 23 June 2026, on the old platform, a hard delete followed by re-attaching a blank template emptied the cases of two clients. It reached me on 29 June. The MySQL binary log ran in row format with 30 days of retention, so the deleted rows were still in it. The server's disk was nearly full, so I decoded the relevant window in memory and wrote a small Python parser that turned the logged deletes back into inserts. I ran them once inside a transaction and rolled it back to check for collisions, then for real. On 1 July the first client's workflow, its 8 steps and 27 documents were back, and the second client's case was restored the same way.

On the new platform, two full recovery sets run every day: a database dump, every stored document and a manifest with a checksum per file. They go to a separate EU bucket that deletes in the live buckets cannot reach, and a monitor alerts when a set is late or incomplete.

How it ships

A push to dev builds the images once and deploys them to a test stand with synthetic data. Production gets the same images: main is fast-forwarded to the exact commit that passed on the stand, and a browser smoke test runs against the built image before promotion. CI runs lint, type checks, Go vulnerability checks and a dependency audit. The Go tests and the Playwright suite run on my machine before a release.

I build it with coding agents, in the loop described in How I ship with coding agents. The decisions above are written down as architecture records in the repository, so the next engineer, or the next agent session, can see why.

Questions about this project: [email protected]