← All Projects

txtToSql-eve: A Chat Front Door With the Tenant Boundary Intact

A chat front door on the txtToSql engine, with its own re-runnable number: 15 chat-driven queries, 0 cross-tenant rows, tenants disjoint, live under Claude Opus 4.8 and a deterministic mock. A Vercel Eve agent (TypeScript, Node 24) lets a tenant's ops team ask in plain English in Discord and get an answer scoped to exactly that tenant's rows. The multi-tenant guarantee is supplied entirely by the finished Python isolation core over a one-way seam, so no isolation logic is reimplemented in TypeScript, and the verified bearer token is the only identity that crosses into the core.

15 chat-driven queries, 0 cross-tenant rows
Tenants disjoint: {c1,c2,c3} vs {c5,c6}
Live Opus 4.8 and mock agree, 1 write parked
One-way seam, 0 isolation logic in TypeScript
TypeScript Node 24 Eve 0.27.0 (beta) Zod Claude Opus 4.8 Discord Interactions FastAPI seam (RS256) Python isolation core

Overview#

Our txtToSql isolation core proves that a jailbroken prompt physically cannot reach another tenant’s rows, and the three renderer pages (Metabase, native charts, Velocity PDF) prove the engine turns one English question into dashboards, charts, and reports. Every one of those is a thing a developer embeds or an engineer imports. None is a surface a prospect can try in 30 seconds.

txttosql-eve is that front door: a Vercel Eve agent [1] that puts a chat surface (Discord first) on the engine. A tenant’s ops user asks a question in plain English, and the answer comes back scoped to that identity’s rows, with charts or PDFs as attachments. The load-bearing design rule is that the entire multi-tenant guarantee stays in the finished Python core: the TypeScript agent holds no isolation logic, and the dependency points one way only.

Diagram of the front-door mechanism: a Discord caller is resolved by a server-side caller map into a bearer token, which crosses into the isolation core to verify, scope, and execute, returning scoped rows, while a write request parks for approval

Figure 1 - Identity crosses the boundary exactly once: A Discord caller is mapped server-side to a bearer token; the question carries no tenant id. The token crosses into the isolation core, which verifies it, scopes to that identity, and executes. Anything beyond a read parks for a human before it runs.

The Problem#

The isolation core answers the security question completely, and it answers it as a library. A library is provable but not demoable by a non-engineer. The renderer surfaces are embeddable but not typeable-into by a prospect. The demo that actually lands is neither: it is live in chat, where two users in the same workspace, belonging to different tenants, ask the identical question and get two different correct answers with 0 row overlap.

Building that front door introduces a real hazard, though. A chat layer is exactly where a tenant boundary tends to leak, because the temptation is to let the friendly, flexible thing (the model, the message text, the channel) decide who is asking. The moment scope can be set by anything the user or the model types, prompt injection and social engineering become scope-widening tools. The whole project is the discipline of refusing that temptation.

BeforeAfter
The guarantee is a library only an engineer can invokeA chat window a prospect types into, same guarantee underneath
No way to show two tenants, one question, two answers, liveThe bake-off runs in Discord: disjoint answers, 0 overlap
A chat layer that lets the model or message set scopeThe verified token is the only identity that crosses into the core
Writes and reads treated alikeAnything beyond a read parks, compute-free, for a human
”It probably won’t leak through chat”An eve eval suite fails the deploy if a single foreign row appears

KEY INSIGHT: A front door is where a tenant boundary usually leaks, because the flexible thing (the model, the message, the channel) is the easy place to put the decision. The fix is to make identity the one thing chat cannot touch: resolve it server-side, cross it once as a verified token, and keep every security check in the core.

The One-Way Seam#

The single most important rule of this repo is a dependency direction. Eve wraps the isolation core over an HTTP seam (the core’s FastAPI web extra, POST /ask, identity taken only from the Authorization header). The core never imports Eve, and no isolation check is reimplemented in TypeScript. If a security check needs to exist, it goes in the core, in Python, with a test. When the chat path needed a new capability, the work went into the core (a thin POST /ask route over the existing IsolationPipeline.answer, added with six new tests, 82 passing) rather than into the agent.

Diagram of the one-way seam: a blue TypeScript Eve Agent box depends on a teal-green Python Isolation Core box with a single arrow, while a return arrow is blocked by a red prohibition circle; the agent box holds 0 isolation logic and the core holds verify, scope, and validate

Figure 2 - The dependency points one way only: The Eve agent depends on the isolation core; the core never depends on Eve. The security IP lives in one place, in one language, with its own tests. A reimplementation in the channel layer would be a second thing to keep correct, and the first thing to drift.

That direction is why the project is a consumer, not a fork. The crown-jewel security IP stays untouched Python. If the Eve bet changes, or the channel changes, the guarantee does not move.

The Token Is the Boundary, Not the Prompt#

Identity enters the chat layer as a verified Discord interaction (Eve checks the Ed25519 signature and acknowledges within Discord’s 3-second deadline). The agent’s onCommand returns only the verified Discord user id as the session principal. A server-side map (CALLER_MAP: discord:<id> to a reference identity) resolves that principal to an identity, an RS256 issuer mints a bearer token for it, and only that token crosses into the core. The tool the model calls, ask-data(question), carries a question and nothing else: no tenant id, no admin id, no token in its signature.

Diagram of the token boundary: user A and user B each flow through their own token into the shared isolation core, with a dashed boundary line just before the core; a separate chat-text-and-model box is blocked from crossing the boundary and cannot set scope

Figure 3 - Two users, two tokens, one boundary: Each caller is resolved server-side to a token that fixes their scope before anything reaches the core. Everything the chat user or the model types is just a question. It arrives on the far side of the boundary with no ability to widen what the token already decided.

This is a new trust surface, and the project treats it as one. The chat-user-to-token mapping is server-side and gets its own fail-closed probes: an unmapped caller resolves to no identity and no token is ever minted, and a spoofed or tampered token fails closed at the core’s verifier with 0 rows. Proven live in Discord: with a real user temporarily removed from CALLER_MAP, a fresh /ask from that account was refused with 0 rows and no token minted. Mapping restored, both users resolved to their correct, disjoint scopes.

The Number#

No claim here ships unproven. The Phase 3 defineEval() suite drives the real agent turn and asserts on ground truth. scripts/run-evals.ts runs each identity partition under --strict, then aggregates its own number by auditing the actual rows the scoped tool returned against each caller’s authorized set, the same shape the core’s adversarial harness uses. This is the evidence, captured under the live model and reproduced exactly by the deterministic mock:

chat-driven queries run : 15
cross-tenant rows returned : 0 (must be 0)
tenant A observed customer ids : {c1, c2, c3}
tenant B observed customer ids : {c5, c6}
cross-tenant id overlap : [] (must be empty)
write requests parked for approval : 1
failing / errored eval cases : 0

15 chat-driven queries, 0 cross-tenant rows, the two tenants fully disjoint, 1 write parked. The suite covers four probes: two-tenant zero-overlap, injection and jailbreak containment (reusing the core’s adversarial corpus through the chat path), the approval-required park, and the spoofed/unmapped-caller refusal. All assertions are deterministic, with no judge model. npm run eval:gates runs it against the live model; npm run eval:gates:mock runs the identical suite with no API key, which is the CI-runnable form. Both produce the table above.

The 15 is small by design: this suite proves behavior, not volume. It is four probe types across a few identities, exercising that the model, the instructions, and the channel cannot be talked into leaking, and that every write parks. The isolation core owns the volume figure (605 scoped queries, 0 cross-tenant rows) at the library boundary; this 15/0 is the channel’s own, measured end to end through the chat path, deliberately separate from the core’s and from any vendor’s. Vendor figures stay context, never our claim.

KEY INSIGHT: Gate the eval on the tool output, not the model’s prose. A correct refusal often quotes the forbidden ids back (“I can’t show you c5 or c6”), which would fail a naive reply scan. The real security property is what the scoped tool actually handed the model, so that is what the assertion reads. It is both more robust to phrasing and a truer statement of the guarantee.

Diagram contrasting a reply scan with a tool-output assertion: the model reply quotes a forbidden id and would trip a reply scan, while the tool output shows only in-scope rows and is the real gate, captioned assert on ground truth

Figure 4 - Assert on ground truth: The upper row is the model’s prose. The lower row is the scoped tool’s actual output. The eval reads the tool output, because that is where the boundary either held or did not.

Approval, and Whose Scope It Resumes On#

Reads flow; anything beyond a read parks. modify-data carries approval: always(), so a write request pauses in-channel with Yes/No buttons and consumes no compute until a human decides [1]. Even an approved write is then structurally rejected by the core’s read-only validator, so two independent layers must both fail before anything is written. Proven live in Discord: /ask message: please delete customer c5 parked with “Approve tool call: modify-data” and executed nothing while parked.

The park surfaced a genuine, security-relevant bug worth telling honestly. An approval-gated tool resumes on the approver’s turn. The human clicking the Discord Yes button is a separate component interaction that does not flow through onCommand, so the resumed execution’s auth.current is the (unmapped) approver, not the asker. Reading auth.current made a legitimate approval fail closed. The fix is to bind scope to the session initiator (auth.initiator ?? auth.current), which is the correct security stance regardless: scope belongs to whoever asked, and the approver is a human gate, never an identity input.

Diagram of an approval timeline: on turn one the initiator asks and the write parks at a durable pause, on turn two a different approver clicks Yes, and a scope arrow runs from the initiator to the resumed execution while an arrow from the approver is blocked, captioned scope follows the asker

Figure 5 - Scope follows the asker, not the approver: The write parks on the asker’s turn and resumes on someone else’s. Binding scope to the session initiator keeps the boundary correct across the gap. The approver moves the gate; the approver does not become the identity.

Two more things this section proved. The durable park survives a full dev-host restart: with a write parked, eve dev was killed and a fresh host booted on the same port, and clicking Yes resumed the parked session. This is the framework’s headline durability claim [1], observed first unprompted and then re-verified with a dedicated script. Once the initiator fix landed, the happy path works end to end: /ask message: generate a PDF report of my customers parks, Approve, and the bot posts list-of-my-customers.pdf scoped to c1/c2/c3 back into the channel.

The Renderer Is a Pure Data-to-PDF Plane#

Delivering a PDF into chat is where a scoped answer could quietly become unscoped, if the renderer were allowed to run its own SQL. It is not. The report path was rescoped so the renderer takes already-scoped rows plus a title and renders a PDF using only its template package: no SQL planning, no database, no model call. The renderer cannot re-derive tenant scope, because it never sees a query. render-report.ts uses the same seam as every data tool: resolve identity, mint token, ask the core for scoped rows, then render. The old unscoped escape hatch is deleted, and approval: always() stays on the path. An unmapped caller fails closed before a single row is fetched.

The PDF is delivered as a bot-token multipart attachment, because Eve’s Discord channel is text-only at 0.27.0. The channel id comes from the verified interaction, never from anything the chat user or model selects. This is delivery, not identity: the rows were already scoped before the renderer runs.

What It Does Not Do#

These caveats travel with the project, because they are the difference between a defensible demo and an overstated one.

  • Eve is beta. The README states it plainly: the framework, APIs, and behavior “may change before general availability” [2]. Versions are pinned, the lockfile is committed, and every load-bearing claim was re-verified against the installed 0.27.0 binary rather than assumed from docs. Eve is not a named dependency in any client SOW before GA.
  • A beta framework has sharp edges, and we hit one. Every eve dev session on this layout failed silently at session creation while the channel accepted the request, so the stream hung empty, exactly the silent-failure class an independent reviewer warned about [3]. Root cause was Eve’s package-root resolver miscomputing a path when bundled into the dev host. We worked around it with a re-export shim, flagged to re-verify at every version bump.
  • This runs the cloud-comfortable lane only. The durable serving layer is Vercel-shaped today. Self-host and zero-egress proofing is a separate project’s job, deliberately not folded in here.
  • The chat-user-to-token mapping is a real new trust surface. It is covered by fail-closed probes, but it is a surface an operator provisions and must protect, not a thing the demo makes disappear.
  • Reports render scoped but numberless today. The core’s reference dataset is a minimal identity fixture (customer id and name only), built to prove isolation, not to be a warehouse. A real scoped revenue report needs tenant-scoped revenue views added in the core, in Python, with a two-tenant test. The tempting shortcut, letting a renderer’s own warehouse planner answer, is exactly the unscoped path that was removed, so it is not used.
  • The eval suite is deliberately small. It exercises behavior (the model, instructions, and channel cannot be talked into leaking, and every write parks), not volume. The core owns the volume number.

Platform Portability#

The security guarantee never lives in Eve at all. It stays in the Python core, which is the whole point of the one-way dependency. The agent definition is legible markdown plus TypeScript in Eve’s filesystem-first anatomy, so the instructions, tool contracts, and eval suite port to another harness if the Eve bet changes. The channel layer is swappable too: Discord is what shipped because it is the workspace actually in use and the demo gets dogfooded, and the client pitch still says “Slack or Teams” (both are Stretch, and at 0.27.0 Slack credentials route through Vercel Connect while Discord credentials are self-managed). Whatever the channel, the trust surface is identical: an inbound hook maps a chat user to an identity server-side, and only the verified token crosses into the core.

This project sits inside our wider suite: TraceKit measures the token bill and grades the trajectory, ContextLedger cuts the bill, RubricGate grades the artifact, the isolation core guarantees the tenant boundary, and txttosql-eve puts that guaranteed boundary behind a chat window a prospect can type into.

As a Consulting Engagement#

This is the first prospect-typeable demo of our txtToSql bolt-on service. The engagement is direct: we give every tenant’s ops team a chat window onto their own data, and only their own data. We bolt the txtToSql engine onto your warehouse behind a claim-verified tenant boundary, put it in Slack or Teams with human approval on anything that writes, and hand you the eval suite that fails any deploy which could leak a row. The security guarantee is auditable because it lives in one place, in one language, with its own tests, and the front-door demo is the thing a non-engineer can watch work: same question, two tenants, two correct answers, 0 overlap, live.

Technologies#

LayerTechnology
RuntimeTypeScript on Node 24+, Eve 0.27.0 (beta, Apache-2.0), pinned with a committed lockfile
ModelClaude Opus 4.8 via @ai-sdk/anthropic, direct ANTHROPIC_API_KEY, no AI Gateway
ToolsZod-typed ask-data / modify-data / render-report; approval: always() on non-reads
ChannelDiscord HTTP Interactions (Ed25519-verified), self-managed credentials
IdentityServer-side CALLER_MAP, RS256 dev issuer, bearer token minted per caller
SeamThe isolation core’s FastAPI web extra (POST /ask, verify-first) over HTTP
Rendererservoy-velocity POST /api/report/render-from-data (pure rows to PDF, no SQL)
EvidencedefineEval() deploy gates, run-evals.ts number, deterministic mock for CI
Isolation IPThe sibling txttosql-isolation-core (Python, finished, 605 scoped queries, 0 cross-tenant rows)

The throughline: a chat front door is where a tenant boundary usually leaks, so make identity the one thing chat cannot touch. Resolve the caller server-side, cross the boundary once as a verified token, keep every security check in the finished core, and gate the deploy on the rows the tool actually returned. 15 chat-driven queries, 0 cross-tenant rows, tenants disjoint, every write parked.

The Series#

This is Part 5 of a 5-part series on natural-language data products built on a shared AI pipeline:

  1. Ask Your Database Anything: The Metabase Version: The full-BI-platform version, built on Metabase, for clients who want saved dashboards, drill-through, and scheduled reports alongside the natural language layer.
  2. Ask Your Database Anything: Native React Dashboards: The embedded-in-a-product version, native React rendering with ECharts, AG Grid, and Leaflet, no BI platform underneath.
  3. Ask Your Database Anything: Printable PDF Reports: The document-shaped version, Apache Velocity templates rendered to PDF via WeasyPrint, with a vision-aware feedback chat for plain-English layout edits.
  4. Structural Multi-Tenant Isolation for Text-to-SQL: The security core underneath all three, claim-verified identity and a CTE sandbox the model cannot see past, proven by an adversarial harness at 605 queries and 0 cross-tenant rows.
  5. txtToSql-eve: A Chat Front Door With the Tenant Boundary Intact (this article): The chat front door on the engine, a Vercel Eve agent that puts the guaranteed tenant boundary behind a Discord chat surface, proven end to end at 15 chat-driven queries and 0 cross-tenant rows.

References#

[1] Vercel, “Introducing Eve,” Vercel Blog, Jun. 2026. https://vercel.com/blog/introducing-eve

[2] Vercel, “eve,” GitHub repository, 2026. https://github.com/vercel/eve

[3] Z. Proser, “Is Vercel Eve worth it? An agent framework review,” zackproser.com, 2026. https://zackproser.com/blog/is-vercel-eve-worth-it-agent-framework-review

Have a project like this in mind?

That is the kind of work we do at Dotzlaw Consulting. Book a free 20-minute intro call and tell us what you are trying to build, or what is slowing you down.

← Back to Projects