Meridian Data Assistant
An AI assistant that answers finance and project questions from a company database, where the database, not the AI, decides what each person can see.
Revenue and fee by contract for FY2025
SELECT project_id AS contract, project_name AS name,
SUM(revenue) AS revenue, SUM(fee) AS fee
FROM rpt.project_financials
WHERE project_level = 'Contract' AND fiscal_year = 2025
GROUP BY project_id, project_name 1.1s You manage 3 of the 6 contracts: $5,234,842 in FY2025 revenue, with $172,893 in fee.
- The other 3 contracts' dollars are hidden from you.
| ContractID | Name | Revenue ▼ | Fee |
|---|---|---|---|
| 3201 | Sierra Water Treatment Plant | $1,893,758 | $123,891 |
| 3101 | Harbor Bridge Seismic Retrofit | $1,847,772 | $145,643 |
| 3102 | Coastal Highway Widening Phase 2 | $1,493,312 | ($96,640) |
| 3104 | Metro Transit Signal Upgrade | Hidden | Hidden |
| 3202 | Northside Stormwater Tunnel | Hidden | Hidden |
| 3203 | Lakeside Campus Utilities Expansion | Hidden | Hidden |
| Total | 6 contracts | no total | no total |
Drawn only from the 6 rows above. A hidden value shows as a gap, never as zero.
Hidden means the database didn't show Alicia Hoang the value. It isn't blank or zero in the records.
Revenue and Fee (3 hidden each) are a project's dollars: shown to the project's managers and to finance roles.
What Alicia Hoang can see, according to the database: no special access grants. Projects they manage: 3. People in their reporting chain: 0.
Under the hood — for the technical folks
I built Meridian in Claude Code, on Azure. The server is Python and FastAPI in a container on Azure Container Apps, which scales to zero, so an idle day costs nothing. The company data lives in Azure SQL Database on the free serverless offer, with Microsoft Entra sign-in only and auditing on. Accounts, invite codes and each guest’s daily spend sit in Azure Table Storage, and app logs plus the SQL audit go to Log Analytics, capped inside the free tier. There are no keys in the code: the app reaches the database and storage with its managed identity. GitHub Actions builds a private container image, and a deploy script moves the site to one exact build.
The permissions live in the database. Row-level security covers all 38 tables. The AI can read only 18 reporting views plus a data dictionary, and the app’s own database identity can read nothing but the reporting views. The AI never chooses whose data it reads: for every query the server opens a fresh, read-only connection and sets the employee on it. Every query has to be a single read-only SELECT, but that filter is a first check, not the limit — the database’s permissions are. An automated checker runs 1,446 permission checks, including attempts to read hidden values through deliberate database errors.
The model is gpt-oss-120b through OpenRouter, at about six seconds a question. Answer quality comes from definitions, checked examples and checks on the results, rather than a longer list of prompt rules. The AI names which of its queries answer the question; each result is checked for the known traps (no rows, a contract mixed with the orders under it, sums across forecast versions); and any dollar figure that no result actually holds goes back to the AI once. The test set is 101 questions tagged by kind. A full run costs about 43 cents, so I ration them. The last measured result was 93 of 95 on the earlier 95-question set, counting each question’s latest run rather than one full pass.
The chat page is React 19 and TypeScript on Vite, with Tailwind v4, shadcn/ui, Motion, TanStack Table and Query, Chart.js, and write-excel-file for the downloads. Its UI tests run on saved real answers, so they never spend AI money: 333 page tests and 68 server tests as of September 22.
Technical highlights:
- Row-level security in Azure SQL on a synthetic company database, checked by an automated test of 1,446 checks
- Identity set by the server, never the model, on a fresh read-only connection per query
- Access that follows real-world roles: corporate finance sees every dollar, business-unit finance sees its own unit, HR sees pay, and export-controlled contracts are visible only to people eligible for them — a blank eligibility status blocks access rather than allowing it
- Admin sign-in with GitHub through Container Apps’ built-in auth, allowlisted by GitHub user ID, because IDs never change or get reused
- Guest sign-in with single-use invite codes that expire in seven days, scrypt-hashed passwords and signed session cookies
- Money guardrails: a prepaid model key capped at $3 with no auto top-up, ten cents of AI per guest per day, a $5 monthly Azure budget alert, and a free database set to pause rather than bill at its monthly limit
- A custom domain with a free managed certificate, kept out of search engines with noindex headers and robots.txt
What I wanted
Meridian started as a study repo for the AZ-900 exam, Azure’s fundamentals certificate. I decided to build one real project instead, so I could say I’ve built something on Azure and can do it again.
I wanted it to be the hard kind of AI tool: the internal kind, where people ask about money and projects and not everyone is allowed to see everything. Most of the work in a tool like that isn’t the AI. It’s identity, secrets, data and deployment, and I wanted to do those parts properly.
How I got it
Plumbing came before the AI. Identity, secrets, data and deployment all run on a student subscription, so I built those first. I put the app in a container on Azure Container Apps because it’s the same shape as the containers in my homelab, and it scales to zero when nobody is using it.
The company is fictional, because real internal data is never public. Its data comes from a seeded, repeatable script: four source systems (ERP, HR, scheduling and planning) plus some deliberately messy legacy tables, about 212,000 rows, with the finance figures tying out to the penny.
The decision that shaped everything else was where the permissions go. I could have written them into the AI’s instructions, but instructions can be argued with. So the database enforces them with row-level security, and the AI never picks who the user is: the server does, on every query. Access follows real-world roles, so corporate finance sees every dollar, a business unit’s finance team sees its own unit, and HR sees pay.
The model took some finding. My student subscription blocks Azure’s own models, GitHub Models shut down in July 2026, and Groq’s free tier took 40 to 60 seconds an answer. I settled on gpt-oss-120b through OpenRouter, which answers in about six seconds, on a prepaid key capped at $3 with no auto top-up, so nobody can run up a bill. Sign-in went the same way: my university requires admin approval for new Microsoft sign-in apps, so the admin signs in with GitHub, which needs nobody’s approval, and guests get invite codes so they can try it without a GitHub account.
I rebuilt the chat page between September 19 and 22, after picking its design direction on a Claude Design canvas: a navy “ledger” look, with Source Serif 4 for the figures and IBM Plex for the text.
On September 22 I ran a full audit. Six AI reviewers, Claude Opus and Sonnet, went through the page, its code, the backend, security and simplicity, and then two more agents tried to disprove every finding. About 100 findings came up, and one was rejected. Two were small security holes: a way to slip a second statement past the query filter, and a way to learn yes-or-no facts about hidden rows through deliberate errors, never the pay amounts. Both are fixed. Neither the audit nor the UI testing spent anything from the app’s model key.
What it does
You pick who you’re asking as — a CFO, a project manager, an engineer, an HR partner — and ask a finance or project question in plain English. The same question gets a different answer depending on that person’s access. Anything they aren’t allowed to see shows as a locked “Hidden” chip, and a “Why are these hidden?” panel says who can see it and why.
Each answer comes with a headline of the key figures, the steps the AI took with its SQL shown, a sortable table in accounting style with totals only where adding up makes sense, a strip of key figures, and a bar or line chart. You can copy the table, or download it to Excel with an “About this query” sheet that records who asked, the question, the time and the SQL.
It’s invite-only. Guests get a single-use code that expires in seven days, and each guest account gets ten cents of AI a day, about 30 questions. If you’d like to try it, email me for a code.
Why it matters to me
It’s the thing I set out to prove: that I can build an AI tool on Azure with the parts that make up most of the real work — identity, permissions, data, cost and deployment — and that I could do it again.
It also runs on very little. The model bill comes out of a $5 prepaid balance, through a key capped at $3 that had used $2.54 by September 22, testing included. Azure itself has cost $0 so far, on the student subscription’s free grants. Guests are capped at ten cents a day each, so an invite code can’t turn into a bill.
What I learned
The bugs that mattered weren’t in the AI. They were in small assumptions around it. The answer table decided whether a column should get a total by guessing from the column’s name, so it added up hourly rates, one person’s pay history, and 31 versions of the same forecast. I flipped the rule: only columns known to add up get a total, never across forecast versions and never across a contract and the orders under it, with a test for every case.
The other one I hit myself, on my phone: “connection dropped.” The logs showed three causes stacked on top of each other. The app had scaled to zero and took about 20 seconds to start. The database had gone back to sleep after about 15 idle minutes despite its 60-minute setting, which I found in the database’s own compute records. And the phone gave up while it waited. The fixes were to check on the database after ten quiet minutes, and to let the page pick up a finished answer after a dropped connection. Neither fix touched the model.
This project taught me to keep the model out of access decisions entirely. The database already knows who is allowed to see what, so the AI writes the query and the database decides what comes back.