Developer blog
We Built an AI Data-Mapping Assistant That Runs Entirely in Your Browser
The full engineering story behind a browser-only AI assistant that drafts Quebec Law 25 data maps: a 1.38M-parameter ONNX model, zero LLM tokens, and the launch-day failures we fixed.
On this page
Key takeaways
- The assistant runs entirely in the browser: a deterministic chatbot authors workflow recipes from 384 templates, a 1,382,017-parameter ONNX model picks each next action from the valid menu, and the page makes zero cross-origin requests. No server inference, no LLM tokens, no telemetry.
- Launch day taught the real lessons: the CSP blocked WebAssembly compilation, raw engine errors leaked to users, the wipe button survived two fixes, and a stale French bundle shipped. Each failure became a standing rule.
- Every output is a draft for human review, never legal advice. The system surfaces flags and never declares a mapping compliant.
The engine wouldn’t start
On launch day, nothing moved. The page loaded. The chat window opened. The AI engine sat there, silent. In the console: WebAssembly compilation blocked by the content-security policy.
We had spent months building a system that runs entirely in the browser, and the browser, doing exactly what it should, refused to let it run. A hash-locked CSP with zero unsafe-inline and zero unsafe-eval is a point of pride until it eats your own engine. The fix was the narrowest one available: add wasm-unsafe-eval to the script-src directive, which allows WebAssembly compilation and nothing else, instead of loosening the whole policy with unsafe-eval. It was a concession we would rather not have made, and it is still the one deliberate dent in an otherwise strict policy. Without it, nothing starts.
That dent set the tone for everything that followed. This post is the story around it: why we built it, how the four layers work, and the failures that taught more than the wins.
The inventory nobody wants to do
Quebec Law 25 requires organizations to map their personal-information holdings. If you’ve ever sat through a data-mapping workshop, you know the part everyone dreads: the inventory, the step before the map, the spreadsheet that rots within a quarter. Consultants bill by the week to produce documents that are stale before they are signed.
The assistant at movahedi.ca/tools/data-mapping-guide/ (also in French) attacks exactly this bottleneck. Answer four questions, get a draft inventory map you then confirm or fix. The interaction is deliberately small: a quick 4-tap mode for people who know their environment, or a guided chat for people who don’t. Four taps. That constraint shaped the entire architecture, because if the frontend could only ask four questions, everything downstream had to be deterministic enough to do something useful with four answers.
There is a temptation in this space to throw a large language model at the problem and call the output a map. We went the other direction. The chatbot authors recipes; it never executes them. It compiles answers into a workflow recipe drawn from 384 deterministic templates. Zero LLM tokens. Make the authoring deterministic and put the small model only where judgment was needed: picking the next workflow action from a valid menu.
Four layers, each in its lane
The architecture has four layers, and the strictness of the boundaries is the whole point.
The chatbot asks the four questions, in either mode, and compiles the answers into a recipe. It authors; it never executes. There is no path from the chat frontend to any mutation of the map. (How the chatbot works is its own article on this blog.)
The MCP layer validates the recipe. execute_mapping_workflow(recipe) checks the recipe against the workflow schema and returns a session ticket. One clarification a technical reader will want immediately: on the assistant page there is no MCP network hop. The repo (github.com/movahedi-ca/AI-data-map, MIT) ships a stdio MCP server (mcp/server.js) for external clients, but the page runs the same contract as an in-page function, window.execute_mapping_workflow. The session ticket lives in the page. Validation is strict: a recipe that doesn’t conform doesn’t run, it gets rejected with a reason. Rejections are a first-class path, not an afterthought. (The MCP article on this blog covers both servers.)
The System-1 model picks each next workflow action. A 1,382,017-parameter tiny transformer, four layers, d_model 128, exported to ONNX, compiled to WebAssembly, running in the browser through onnxruntime-web. Its one job is picking the next workflow action from the valid menu, given the session state. The recipe says what to build; the model decides how, one step at a time, always as the argmax over the valid menu, never bypassed, never able to invent an action outside it.
The honest question is why a learned model at all, when the recipe already lists every intent. Two answers. First, the model handles menu variation the template author didn’t enumerate, and the out-of-domain result (0.9420 on 207 steps across 10 non-mapping domains, with the qualifications the model article states plainly) is the evidence it does. Second, future-proofing, stated as such: the same machinery picks actions for any workflow menu, so new instantiations don’t need re-engineering. If that sounds like a bet rather than a proof, it is.
The tools do exactly what the workflow steps say and nothing else: the mapping canvas, the Excel importer and exporter, the review loop.
The model and the training data are public: huggingface.co/movahedi-ca/s1-workflow-tiny holds the weights, and huggingface.co/datasets/movahedi-ca/ai-data-map-teacher holds the 526 scripted-teacher steps across 33 shards (one shard per recipe scenario). The companion article on this blog covers the training and evaluation in depth, including what the eval numbers do and do not prove.
Privacy by architecture, not by policy
Privacy policies are promises. We wanted architecture instead.
No server inference. No telemetry. No accounts. No runtime third-party requests: dependencies like onnxruntime-web are vendored at build time, and the model weights ship with the page as same-origin files. A live request counter on the page counts cross-origin requests and reads 0 throughout the session, and it is there precisely so a skeptical user can watch it stay there. One click wipes the session. Every output is a draft for human review, never legal advice. The review loop is the last gate: the draft map lands in grouped editing cards, and nothing exports to Excel until a human has confirmed or corrected each group. The export button is, in effect, the signature line. Retention values are ranges that cite the statute with an as-of date, because statutes change and a hardcoded number becomes wrong the day an amendment passes. And the conservative choice, everywhere: the system surfaces flags and never declares a mapping compliant. A tool that stamps “compliant” on a draft inventory invites reliance it cannot support.
One click wipes the session. That sentence is short because the story behind it is long, and it comes later in this article.
The honest build log
Engineering posts that skip the failures are launch announcements with better fonts. Here are ours, named.
The engine wouldn’t start. Covered above: CSP versus WebAssembly, fixed with wasm-unsafe-eval on script-src. Lesson: a hash-locked CSP is a deployment constraint, not a background setting. Test the production policy in a real browser before launch day, not on it.
Raw errors reached users. Early builds surfaced the engine’s raw exceptions and the parser’s raw rejections. Concretely: pasting a malformed recipe produced the JavaScript parser’s own complaint, something like Expected property name or '}' in JSON at position 12, sitting in the chat where a person doing a privacy inventory was supposed to understand it. We replaced these with plain-language messages (“That recipe didn’t paste cleanly. Check the quotes and brackets and try again.”) and, just as important, a visible Start-over recovery path. The raw diagnostics still go to the console for us; they just never reach the user. A failure state without a recovery path is a dead end. Standing rule: raw engine errors never reach users.
The review UI read like an error log. The first review screen showed the draft map as a series of declarative sentences: “I put X here as Y. Right?” It sounded like the machine confessing. The person this was built for, testing it himself, killed that design, and then killed its replacement too. Round two had grouped cards but no editing: you could confirm or start over, nothing in between. What survived is a calm editing workspace where each card can be renamed, retyped, set up with details, or removed inline. The lesson took two rounds to land: the review loop is the product. Nobody cares how clever the recipe compilation is if confirming it feels like reading a log file.
The wipe button survived two fix attempts. This one deserves the detail. The “one click wipes the session” button failed its first fix, failed its second, and the root cause only surfaced on the third pass. It used a native browser confirm dialog, which test automation silently dismisses, so in the automated path the wipe aborted and the tests couldn’t see anything wrong. On the human path it was worse: the wipe cleared only the canvas while the assistant’s state survived and repainted the old map right back. So the button appeared to work to a casual glance and did nothing. The wipe path is the highest-stakes path in the whole product, the one privacy claim users will actually test, and it took three attempts to make it real. We now treat the wipe as a first-class flow with its own test sequence: force the wipe mid-session while the assistant holds state, then assert that a fresh load shows an empty canvas and a fresh ticket, in both locales. Lesson: test the recovery paths with forced failures, in a real browser, not checklists. A privacy tool whose delete button lies is worse than no tool at all.
A stale French bundle shipped. The English bundle got a fix; the French one didn’t, and nobody noticed until both were live. The failure mode is obvious in hindsight: two locales, one manual sync step, zero verification. The fix works because of a design detail: the assistant bundle (s1/) ships both languages’ strings and picks the locale at runtime, so the engine bytes are identical per locale and byte-identity is checkable. Standing rule now: both locale s1/ trees are diffed identical before every deploy. It takes seconds and would have caught the bug.
What these taught, condensed: real-browser QA with forced failures and recovery paths, not checklists. Raw engine errors never reach users. Per-locale bundle sync as a standing rule. And the wipe button is the product’s most important feature, because it is the one users verify.
What’s next
The executor is vocabulary-agnostic within its teacher family, and the out-of-domain eval number, with the qualifications stated in the model article, is the evidence. Data mapping was the first instantiation; the machinery for authoring a recipe, validating it, picking actions from a menu, and running them on a canvas does not know it is doing privacy work. New workflow instantiations are possible, and the next one will have its own article when there is something honest to say about it.
Two open threads, deliberately unresolved. First, retraining: the action vocabulary will grow, and we don’t yet know the growth rate at which the tiny model’s accuracy starts to sag, or whether a fresh teacher-data pass will be enough to recover it. The retraining pipeline exists; the cadence doesn’t, because we haven’t earned it yet. Second, the standing rules: every one of them came from a specific failure, and we are still collecting the failures that will produce the next ones.
That is also the deal this dev blog makes: every technical build on movahedi.ca gets one of these articles, failures included.