All projects

Case study

Brownie

An AI workspace that fills real forms from your documents, shows its sources, and changes nothing until you approve.

1,820 backend tests behind about 72,000 lines of Java

Who had the problem#

Someone always has to write up the minutes after a meeting, or fill in the membership application, the equipment request or the reference letter. The facts already sit in a transcript or a page of notes. Getting them into the form still means copy, paste and fixing whatever the paste just broke.

AI writers make that faster and riskier. They can invent a date, scramble a header or a logo, and they rarely show where a value came from. On an official form, one invented value is worse than a blank, so the person filling it ends up checking every line anyway.

What I built#

Brownie is an AI workspace I'm building on my own. You upload the form in whatever format it came in (Word, PDF, Pages, RTF or OpenDocument) along with your source notes. Brownie finds the blanks, fills them from passages in your sources, and shows the passage behind each value it filled.

Then it checks the document, waits for your approval, and exports the form itself, filled in, as Word or PDF. Values the model proposes stay proposals until you approve them.

Around that core sit the parts a real service needs: Microsoft Entra External ID sign-in with server-side sessions, virus scanning, and a trash bin with verified deletion. A "Your data" page reads its retention periods from the same settings the worker enforces, so it can't promise a period the system doesn't keep. The Azure infrastructure for an invite-only pilot is written as code.

How it works#

The backend is 5 Maven modules (api, core, storage, ai, worker) on Spring Boot 4 and Postgres, with 49 Flyway migrations and row-level security on 47 tables. The API, the worker and migrations each sign in as their own least-privilege database role, and neither runtime role can change the audit log or the deletion ledger except through database routines. The diagram shows one form's trip from upload to export.

One form's trip from upload to exportAn upload is classified by its bytes, quarantined until ClamAV scans it, walked as a Word package and converted in a throwaway LibreOffice container. Extraction splits the sources into citable passages, every model request is reserved in a spending ledger first, and an answer that cites a passage the run never offered is rejected.Uploadclassified by itsbytes, never its filenameQuarantineuntil ClamAV scans itWord packagewalkat most 500 parts and200 MiB expandedLibreOfficecontainerthrowaway: no network,read-only root, macrosoffExtractionsources split intocitable passagesSpending ledger$0.10 per run, $2.00per workspace eachmonthOpenAIclient retriesswitched offCited answeran answer that cites apassage the run neveroffered is rejectedExporteach requestreserved firstsettledOne form's trip from upload to exportAn upload is classified by its bytes, quarantined until ClamAV scans it, walked as a Word package and converted in a throwaway LibreOffice container. Extraction splits the sources into citable passages, every model request is reserved in a spending ledger first, and an answer that cites a passage the run never offered is rejected.Uploadclassified by itsbytes, never its filenameQuarantineuntil ClamAVscans itWordpackagewalkat most 500parts and 200MiB expandedLibreOfficecontainerthrowaway: no network,read-only root, macros offExtractionsources split intocitable passagesSpending ledger$0.10 per run, $2.00 perworkspace each monthOpenAIclient retries switched offCited answeran answer that cites apassage the run neveroffered is rejectedExporteach requestreserved firstsettled
One form's trip from upload to exportDashed box: an AI model or agent
  1. Every upload is classified by its bytes, never its file name.
  2. It stays in quarantine until ClamAV scans it.
  3. Word packages are walked before any parser sees them: at most 500 parts and 200 MiB expanded, no DTDs, no remote templates.
  4. Conversion and rendering run in a throwaway LibreOffice container with no network, a read-only root and macros off.
  5. Extraction splits the sources into citable passages.
  6. Each model request is reserved in the spending ledger at the real rate card before it is sent to OpenAI, then settled with the true token counts.
  7. An answer that cites a passage the run never offered is rejected.
  8. The form is exported.

Every upload is classified by its bytes, never its file name, and stays quarantined until ClamAV scans it. Word packages are walked before any parser sees them (at most 500 parts and 200 MiB expanded, no DTDs, no remote templates), and conversion and rendering run in a throwaway LibreOffice container with no network, a read-only root and macros off. Extraction splits the sources into citable passages, and any answer that cites a passage the run never offered is rejected.

Model spending works like a card hold at a hotel. Each request is reserved in a ledger at the real rate card before it is sent, then settled with the true token counts, so an allowance ($0.10 per run, $2.00 per workspace each month) is never discovered by overspending it. The OpenAI client's own retries are switched off, so nothing reaches the provider that the ledger didn't reserve first.

The hardest part#

Delete forever has to mean forever, even after a database restore. A backup holds everything as of the moment it was taken, so restoring one would quietly bring back documents people had already deleted.

So the worker copies every finished deletion (ids, times and counts, never a title) into its own blob container, and a deletion only counts as verified once that copy exists. After a restore, the worker starts in a replay mode that applies each recorded deletion again and exits 0 only when nothing is left pending, so a restore script can't mistake a partial replay for a finished one.

The trap was ids. A restore winds id sequences back, so a new document can end up with the id of a deleted one, and a naive replay would delete the wrong thing. Each entry names its target by id and by the exact moment it was created, and a restore-drill script rehearses the whole sequence on a throwaway Postgres.

Results in numbers#

Brownie runs end to end locally: upload, fill, check, approve and export. About 72,000 lines of backend Java are backed by about 72,000 lines of tests (1,820 test methods), and most API and worker tests run against real Postgres, Azurite and ClamAV containers.

About 1,300 of those tests need no Docker and finish in under a minute, and a full clean verify takes 5 to 6 minutes. The Vue app adds 48 unit test files (25 of them run jest-axe accessibility scans) and 13 Playwright end-to-end specs, and the OpenAPI contract (86 paths under /api/v1) generates the app's TypeScript types. CI runs the backend suite, the frontend tests and build, and a gitleaks secret scan on every push.

There are no outside users yet. Nobody gets an invitation to the Azure pilot until I sign in with 2 unrelated real accounts and confirm neither can see the other's documents.

What's next#

Next is real people. The Azure pilot is written and waiting: Bicep for every resource, GitHub deploys federated over OIDC with no Azure credential in the repo, and automatic rollback when a health check fails. The last gate is my own hand check with 2 real accounts, then the first invitations.

On the product side, extraction cites plain-text sources today, and the next step is splitting Word and PDF sources into citable passages too. I also want the Playwright suite running in CI, which means starting the backend stack there.