Security & data handling
The short version: NemulAI never connects to your database. You run every query yourself, review it first, and choose what to send. Everything below describes how that is enforced rather than promised.
How the aggregate audit works
- 1
Computation happens in your warehouse
NemulAI generates SQL. You read it, run it, and see the output before anything is sent. There is no agent, no connector, and no network path from us to your data.
- 2
The SQL creates one view: nemulai.margin_summary
It aggregates your usage and revenue tables to one row per customer per period. Request-level rows are collapsed inside your warehouse and never leave it.
- 3
You upload the summary
A file of typically 10–100 rows. You can open it in a text editor first — it is small enough to read in full, which is the point.
- 4
NemulAI validates, and refuses if it cannot be trusted
If cost is unconfigured, revenue is missing, or coverage is too thin, the report is blocked rather than produced with a caveat. A confident wrong number is worse than no number.
Columns included
- script_version
- tenant_id
- period_start
- period_end
- requests
- input_tokens
- output_tokens
- revenue_usd
- cost_usd
- margin_usd
- margin_pct
- rows_missing_customer
- rows_missing_timestamp
- tenants_without_revenue
- data_quality_flags
This list is generated from the same constant the SQL generator uses, so it cannot fall out of date with what is actually sent.
Never included
- Request-level usage rows
- One row per customer per period is the finest granularity that leaves your warehouse. Individual requests are aggregated away before the summary is produced.
- Prompts and completions
- The generated SQL never reads message content. There is no column for it, and no free-text column at all except a diagnostics flag list we emit ourselves.
- End-user identifiers
- tenant_id is whatever your own schema calls a customer — usually an account id. No user ids, emails, IPs, or session identifiers are selected.
- Warehouse credentials
- You run the SQL. NemulAI has no connection string, no read-only role, and no network path to your warehouse.
- API keys or model provider credentials
- Nothing about the audit path touches your inference providers.
Re-uploading a later month
Re-uploading replaces only the periods contained in the file. Previously uploaded months are left untouched, so the month-over-month comparison survives.
Concretely: uploading August into an audit that already holds July replaces the August rows only. July is untouched, which is what makes the month-over-month comparison possible at all.
This audit was generated from customer-side aggregate SQL. NemulAI received summary rows only, not request-level usage data.
What crosses the boundary, at each step
Step 1 — Schema verification
Sent
- ✓Table names
- ✓Column names
- ✓Data types and nullability
- ✓Declared keys
Never sent
- ✕Credentials
- ✕Hostnames
- ✕Network access
- ✕Any table contents
You run a generated query in your own console and paste the result. The query reads system catalogs (information_schema and equivalents) and contains no DML — it is structurally incapable of returning a row of business data. Selecting a warehouse changes a string in local state; the generators are pure functions with no network call, which is asserted by a test that stubs fetch and XMLHttpRequest.
Step 2 — Mapping and readiness
Sent
- ✓The parsed schema
- ✓Any mapping corrections you make
Never sent
- ✕Anything new — this step adds no data
Analysis runs anonymously and persists nothing. A project row is only created when you choose to save one, and can be owned by an email address before an account exists.
Step 3 — The audit
Sent
- ✓Aggregated usage rows: customer id, timestamp, model, token counts, duration, status
- ✓Billing rows: customer id, amount, period
- ✓Infrastructure cost totals, if you have them
Never sent
- ✕Prompts
- ✕Completions
- ✕Message content
- ✕End-user identities
You run read-only, column-limited, time-bounded SQL that we generate and you review. The SELECT lists only the columns the audit needs. You export the results and upload them — we never pull.
Controls
No connection to your database, ever
There is no warehouse connector, credential form, OAuth flow, or server-side query execution in the product. This is an absence of capability, not a policy — there is no code path that could reach your database.
Row-level security across the database
28 migrations enable Postgres RLS. Application routes read through a service role; authenticated users can only ever select their own rows. Tenant isolation is enforced by the database, not by application filters that could be forgotten.
Encryption at rest for credentials we do hold
Where you optionally connect a router or gateway account, the API key is stored AES-256-GCM encrypted via crypto.subtle with a 32-byte key held outside the database. Read-only scopes are requested wherever the provider offers them.
API keys with real entropy
Agent keys are a prefixed 59-character random string drawn from an unambiguous charset — roughly 340 bits — generated by pgcrypto gen_random_bytes in the database rather than in application code.
Rate limiting on every public endpoint
A sliding-window limiter backed by Upstash Redis, with an in-memory fallback so a Redis outage degrades rather than disables the limit. Applied to schema analysis, imports, checkout, magic links and report generation.
Signed webhooks only
Stripe events are verified against the endpoint signing secret before anything is written. A payment redirect is never trusted as proof of payment — the audit unlocks on the verified webhook alone, because anyone can reach a success URL by editing the address bar.
Passwordless sign-in with PKCE
Magic links use the PKCE flow, so a link is bound to the browser that requested it. The auth callback rejects absolute-URL redirect targets, closing the open-redirect path.
Content Security Policy
default-src 'self', with connect-src limited to our own origin and Supabase. No third-party analytics or session-recording scripts run on authenticated pages.
Audit logging
Privileged and billing-relevant actions — plan changes, payments, exports, admin operations — write an audit record with actor, resource and timestamp.
Reports refuse to be misleading
A margin report whose pricing coverage is too thin is not rendered as a PDF at all. A PDF outlives the conversation and gets forwarded past its caveats, so a version that states a confident wrong number is never allowed to exist as a file.
Limitations we would rather state than have you discover
- •Uploaded exports are stored in our database so your report can be regenerated. Delete an audit and its rows go with it.
- •Redshift, Snowflake and BigQuery metadata queries do not extract declared relationships in this version — joins are inferred from column names instead, and the UI says so.
- •Imported audits carry modelled cost, not measured. Every figure derived that way is labelled modelled everywhere it appears, including in the PDF.
- •We are not SOC 2 certified. The security questionnaire covers our current controls in full.
Sending this to a security reviewer? The security questionnaire answers the standard CAIQ-lite set, and the DPA covers processing terms.
Found something wrong on this page? kevin@nemulai.com — we would rather fix it than have it stand.