We Thought Our AI Fallback Was Local. Then We Traced the Data Flow.
How a conversation with a programmer skeptical about AI led us to redesign Zorgax around explicit trust boundaries, local processing, fail-closed policies, minimization, and auditable AI egress.
A few days ago, I had a conversation that changed the way I was thinking about AI privacy.
It wasn't with an AI researcher.
It wasn't with a privacy consultant.
It was with Nicola's father, a programmer who works inside a company.
We were talking about AI and how companies decide whether employees should be allowed to use it.
His concern was practical.
At his workplace, lawyers are involved in deciding what AI tools can be authorized because employees may work with personal information, internal company information, or other data that should not simply be sent to an external system.
And the underlying question was very simple:
What does the AI actually take, and where does that data go?
That question stayed with me.
Because it's easy for developers to answer privacy questions with architecture labels:
“It's local.”
“We use Ollama.”
“We don't train on your data.”
“We have a private mode.”
But none of those statements, by themselves, tell you what happens to a specific piece of information after a user presses Send.
So I went back to Zorgax, the AI layer we're building inside MyZubster, and started tracing the actual data flow.
That's when we found something uncomfortable.
We Had Something Called “Ollama”
Our model router could distinguish between OpenAI and an alternative provider labelled ollama.
At first glance, that sounds like a privacy boundary.
OpenAI means external processing.
Ollama means local processing.
Simple.
Except that wasn't what the complete runtime path actually guaranteed.
When we followed the fallback path through the assistant service, we found that the non-OpenAI fallback could eventually call a public AI gateway.
Conceptually, we had something like this:
user input
↓
web search
↓
model routing
├── OpenAI
│
└── "ollama"
↓
public AI gateway
The label and the network boundary were telling two different stories.
The problem wasn't Ollama itself.
We already had a genuine local Ollama integration elsewhere in the codebase, using a loopback endpoint such as:
127.0.0.1:11434
The problem was that a provider label had become a proxy for a privacy guarantee.
Those are not the same thing.
If a path is called ollama but eventually sends the prompt to a public gateway, it isn't local processing.
That became the first rule of the redesign:
Privacy properties must follow the actual data flow, not the name of the provider.
The Question Changed
Initially, the architectural question had effectively been:
Which AI model should handle this request?
After that conversation and the code review, we changed it to:
Is this data allowed to cross this trust boundary at all?
Model selection comes later.
This distinction matters.
Imagine an employee pastes something containing:
Customer: jane@example.com
Internal project: Aurora
API token: ...
A traditional AI router might ask:
Which model should answer this?
A privacy-aware router needs to ask something before that:
Is this content allowed to leave the machine?
Only if the answer is yes should external model selection even become relevant.
Privacy Became a Policy Decision
We introduced an explicit privacy policy layer for Zorgax.
Instead of treating every prompt as equivalent, data can now be classified into four categories:
PUBLIC
INTERNAL
CONFIDENTIAL
PII
Those classifications map to processing modes:
EXTERNAL_ALLOWED
LOCAL_ONLY
DENY
The important part is the default.
Missing classification does not mean:
probably public
It means:
INTERNAL
→ LOCAL_ONLY
In other words, the system fails closed.
We would rather unnecessarily process something locally than accidentally interpret unclassified information as permission to send it outside.
Classification Is Not Authorization
This was another important distinction.
Even if content is classified as:
PUBLIC
that alone does not authorize external processing.
We added a separate control:
externalProcessingAllowed
For an external AI/search operation to proceed, both conditions have to be satisfied:
data classification permits external processing
AND
trusted server-side policy explicitly permits external processing
Conceptually:
PUBLIC + authorized
→ external processing may be allowed
PUBLIC + not authorized
→ external processing denied
INTERNAL
→ local only
CONFIDENTIAL
→ local only
PII
→ local only
This separation is deliberate.
A browser should not be able to say:
{
"classification": "PUBLIC",
"externalProcessingAllowed": true
}
and thereby grant itself permission to send data externally.
Our route tests specifically protect against turning client-controlled values into trusted privacy authorization.
Product entitlement is also not privacy authorization.
Paying for a plan, enabling a feature, or having access to a model does not automatically mean that data is authorized to leave a trust boundary.
Sensitive Data Can Override the Declaration
There's another obvious problem with accepting classifications literally.
Someone could classify this as public:
My email is jane@example.com
Or accidentally include an API key inside an otherwise harmless request.
So Zorgax performs sensitive-data detection independently of the declared classification.
The policy layer currently looks for categories such as:
email addresses
IPv4 addresses
Italian fiscal codes
IBANs
payment-card candidates
Bearer tokens
API keys
password/secret/token assignments
private-key material
If sensitive material is detected, it can override a declared PUBLIC classification.
So this:
declared = PUBLIC
detected = PII
does not become externally processable just because someone supplied the word PUBLIC.
The effective classification remains sensitive.
That gives us an important invariant:
User-provided classification cannot neutralize sensitive-data detection.
Local Means Loopback
We also tightened what LOCAL_ONLY actually means.
We introduced a dedicated local AI service for Zorgax using Ollama.
By default:
http://127.0.0.1:11434
with a local model such as:
qwen2.5:3b
But there is a subtle detail here.
We don't accept an arbitrary URL and call it “local”.
For the current LOCAL_ONLY boundary, the Ollama endpoint must resolve explicitly to loopback:
127.0.0.1
localhost
::1
A machine somewhere on a private LAN is not silently considered equivalent to local processing.
That could eventually become another policy category — perhaps something like PRIVATE_NETWORK — but it is a different trust boundary and should be represented as one.
So:
LOCAL_ONLY
now means something technically testable.
Not “probably inside our infrastructure.”
Not “an Ollama-compatible server.”
Not “private enough.”
Loopback.
Web Search Is Also Data Egress
One of the easiest mistakes in AI privacy architecture is to focus entirely on the LLM.
But sending a search query to an external search provider is also egress.
Zorgax can interact with external search systems such as Brave Search, Tavily, and public information sources.
So we treat:
OpenAI
public AI gateway
external web search
as external destinations.
The policy decision happens before those calls.
This also changed the order of operations.
Instead of:
user input
↓
search the web
↓
decide which model to use
the architecture becomes closer to:
user input
↓
classify
↓
evaluate policy
↓
decide permitted egress
↓
search / model processing
That ordering is critical.
There is little value in preventing a sensitive prompt from reaching an LLM if you already sent part of it to a search provider first.
We Added Minimization Too
Authorization doesn't mean we should transmit everything.
For permitted external processing, we introduced a minimization layer.
Before an authorized external request is sent, Zorgax can:
- normalize the text,
- redact recognized sensitive patterns,
- limit payload length,
- minimize history,
- and send only the information needed for that operation. Conceptually: PUBLIC ↓ external processing authorized? ↓ yes minimize ↓ external provider
But minimization is intentionally not a permission mechanism.
We don't do this:
PII
→ redact something
→ magically call it PUBLIC
→ send externally
Classification and authorization are evaluated against the original content.
Minimization is defense in depth after an external operation has already been permitted.
Conversation History Counts Too
A prompt is not necessarily the only sensitive thing in an AI request.
Consider:
Previous message:
"My customer is jane@example.com"
Current message:
"Can you summarize that?"
Looking only at the latest message would incorrectly classify the second request as harmless.
So the Zorgax privacy path evaluates the relevant conversation context too.
Sensitive information in recent history can force the operation into:
LOCAL_ONLY
even when the current message contains no sensitive information.
This is particularly important for assistants because privacy decisions have to follow the actual payload sent to the model, not just the latest textbox value.
Then We Made the Boundary Auditable
A privacy rule that nobody can inspect later is difficult to operate in a company.
So we added a privacy audit layer.
For a decision, the system can record information such as:
classification
processing mode
destination
allowed / denied
reason
content digest
timestamp
But we deliberately avoid putting the raw prompt or conversation history into the privacy audit record.
That would create a strange anti-pattern:
Build a privacy system to prevent sensitive prompts from escaping, then copy those same prompts into an audit log.
The local audit file is created with restrictive filesystem permissions.
This isn't yet a complete enterprise audit platform. We still need things such as centralized lifecycle management, configurable retention, and explicit administrative access controls.
But it establishes another principle:
Audit the privacy decision without unnecessarily duplicating the data that caused the decision.
The Architecture Now Looks Different
The original mental model was roughly:
USER
│
▼
ZORGAX
│
├──── OpenAI
│
├──── Web Search
│
└──── "Ollama"
│
└──── possibly public gateway
The new model is closer to:
USER INPUT
│
▼
┌────────────────┐
│ CLASSIFICATION │
└───────┬────────┘
│
▼
┌─────────────────┐
│ PRIVACY POLICY │
└────────┬────────┘
│
┌────────────┼─────────────┐
│ │ │
▼ ▼ ▼
PUBLIC LOCAL_ONLY DENY
│ │ │
▼ │ └── X
External authorized? │
│ │ │
NO YES │
│ │ │
X ▼ ▼
MINIMIZE LOOPBACK
│ OLLAMA
▼
PERMITTED EGRESS
│
┌──────┼─────────┐
▼ ▼ ▼
OpenAI Search Public AI
Gateway
+
PRIVACY AUDIT
The fundamental change isn't the addition of another AI model.
It's the addition of an explicit decision point before the trust boundary.
We Tested the Boundary, Not Just the Functions
This part mattered to me.
It would have been easy to write unit tests saying:
PII → LOCAL_ONLY
and stop there.
But that doesn't prove the runtime won't accidentally make an external request somewhere else.
So we added runtime tests designed around the network boundary itself.
For example, in the PII path, the fetch mock is configured so that any network request is rejected unless its destination is the local Ollama loopback endpoint.
That means a regression attempting to send PII to:
OpenAI
Tavily
Brave
Wikipedia/public sources
the public AI gateway
causes the test to fail.
We also test that:
- history containing PII forces local processing;
- remote OLLAMA_URL values are rejected for LOCAL_ONLY;
- external processing requires explicit authorization;
- declared PUBLIC doesn't override detected secrets or PII;
- public routes cannot simply supply their own trusted privacy authorization;
- Build Planner web sourcing respects the same external boundary;
- external payloads are minimized;
- audit records don't contain the raw prompt/history;
- provider identity reflects the actual processing path. Before integration, the Evidence Graph + privacy gate completed: 19 test suites passed 97 tests passed 0 failed
That number isn't a compliance certificate.
It's evidence that the technical invariants we implemented behaved as expected under those tests.
Then Production Taught Us Another Lesson
After the implementation was tested and merged, we restarted the backend.
It didn't come back.
Interestingly, the privacy architecture wasn't the problem.
We discovered that the systemd service expected a startup wrapper that was no longer present in the working tree.
The wrapper had originally been introduced to initialize backend MongoDB before opening the gateway listener.
We found an existing copy, compared it against its historical Git version, and verified the two files were byte-for-byte identical using SHA-256.
We restored it.
Then the application still wouldn't start.
This time Node exposed a pre-existing JavaScript syntax error in an authentication route caused by an ASCII apostrophe inside a single-quoted Italian message:
l'automazione
We fixed the two affected strings using the already-correct representation from the main branch and ran syntax checks again.
Finally we started the service.
The result:
active (running)
Connected to MongoDB
MyZubster Gateway listening on:
127.0.0.1:5003
Backend MongoDB initialized
That deployment incident reinforced exactly the same lesson as the privacy work.
Don't trust the label. Verify the runtime.
A Git merge is not proof that production is running the expected behavior.
A provider called ollama is not proof that processing is local.
A configuration file is not proof that a process successfully loaded it.
At every layer, the interesting question is:
What actually happened?
What We Did Not Build
It's important to be precise here.
We did not build a button called “GDPR compliant.”
And we're not claiming that this architecture, by itself, makes an organization GDPR compliant.
Legal compliance involves considerably more than routing prompts:
- lawful basis and purpose,
- organizational policies,
- contracts and processor relationships,
- retention rules,
- data-subject rights,
- access controls,
- deployment configuration,
- governance,
- and the actual context in which a company uses the system. There are also technical capabilities we haven't completed yet. For example, we still want stronger provider allowlists, configurable retention, centralized audit lifecycle management, and more explicit administrative controls. What we've built is narrower. And because it's narrower, we can test it. We built technical controls intended to make this statement meaningful: Sensitive or unauthorized AI data should not silently cross an external trust boundary.
That's a much more useful engineering target than putting a “private AI” badge on the interface.
Back to Nicola's Father
This whole piece of work started with a programmer expressing skepticism about AI in a company.
And I think that skepticism is healthy.
When companies ask:
“Can our employees use AI?”
the answer shouldn't start with a model benchmark.
It should start with questions like:
What data enters the system?
How is it classified?
What leaves the company?
Which external providers can receive it?
Who authorizes that?
Can sensitive data override an unsafe declaration?
What happens when external processing isn't authorized?
Is there a genuinely local path?
What gets written to audit logs?
Can we demonstrate those boundaries with tests?
Can we verify what is actually running?
Those questions are much less exciting than announcing another AI feature.
But for enterprise adoption, they may matter much more.
The conversation with Nicola's father made the problem concrete for me.
He wasn't asking whether AI was impressive.
He was asking whether a company could understand and control what happened to its information.
That's the problem we're now trying to engineer around.
The Biggest Lesson
Before this work, I might have described the architecture by saying:
“Zorgax can use a local model.”
Now I don't think that's enough.
The better questions are:
When must it use the local model?
What prevents it from using an external one?
What prevents web search from leaking the same information first?
Who is allowed to authorize external processing?
What happens when PII appears in conversation history?
What does "local" technically mean?
And what test fails if any of those guarantees regress?
That's a very different way of thinking about AI architecture.
Privacy stops being a feature of the model.
It becomes a property of the path the data is allowed to travel.
And perhaps the most important lesson from this implementation was the simplest one:
Don't trust the label.
Trace the data flow.
Enforce the boundary.
Test the egress.
Verify the runtime.
That's where we're taking Zorgax next.
Top comments (1)
the loopback tightening is the detail i'd steal. accepting any url and calling it "local" is such a natural shortcut, and the moment it's configurable it stops being a guarantee. making local mean "must resolve to loopback" turns a vibe into something testable.
also glad you treated web search as egress. that's the leak everyone builds first: they secure the model call and forget the query went to tavily three steps earlier. putting classify > policy > egress decision before the search fixes it before it happens instead of after.