A question becomes a program
millfolio lets a powerful remote model help with your private data without ever seeing it. The trick — the Enclave harness — is to treat the frontier model as an untrusted code generator, not a data processor. Here's how one question turns into code that runs on your Mac.
Two asymmetric models
There are two models, deliberately asymmetric:
- The frontier model (untrusted) is the planner/coder.
It sees only a sanitized manifest of your vault — file
aliases (
file_0), kinds, aliased column schemas (col_2) — plus the category tag names (phone,travel…) it can filter on, and never contents, names, values, or your keyword rules. From that it writes one Mojo program that calls a fixed set of vault tools. - The local model (trusted, on your device) is the
reader. When the program needs to understand content — "is this a
travel expense?", "read the renewal date" — it calls
ask_local(...), which runs on the millfolio inference engine and is the only thing that ever sees real text.
file_0, col_2) and the
category tag names (phone, travel…) — never
your files, your data, or your keyword rules. The frontier-written program then runs as a
separate, sandboxed process that can reach the network only over loopback
— so inference is a local service it talks to over HTTP, not an in-process
call. That's why there are two servers (and why they stay two: the app restarts without
reloading the ~7 GB model). Both run under launchd.
The contract the frontier model is given is a single document — the
enclave system prompt — loaded at runtime. It
spells out the confidentiality rules, the current Mojo dialect, and the
tool surface (search, csv_rows,
pdf_text, docx_text, ask_local, print_answer, …).
Example: a question becomes a program
You ask "How much did I spend on travel last year?". The frontier model — seeing only aliases and the tag names — writes this, and the Enclave compiles it and runs it in a sandbox that can reach only your local model:
# written by the frontier model — it never sees a single real value
from vault import *
def main() raises:
var hits = search("travel transportation flights hotels expenses", 40)
var total = 0.0
for c in hits:
# ask_local reads the REAL chunk on-device; returns "amount|yes" or "0|no"
var verdict = ask_local(
"If this is a 2025 travel expense, reply '<amount>|yes', else '0|no'.", c.text)
var parts = verdict.split("|")
if len(parts) == 2 and String(parts[1]) == "yes":
total += atof(String(parts[0]))
print_answer("You spent about $" + String(total) + " on travel in 2025.")
The frontier model orchestrates over aliases; search and
ask_local do the real work locally; the sum is computed on
your machine and print_answer surfaces it there. The
search results and the answer are never returned to the
frontier model — which is exactly why the program model is load-bearing,
not an implementation detail.
Why it holds
Containment lives outside the model, at the OS level. The generated program runs under a Seatbelt profile that denies all network except loopback to your local engine — it can't phone home. An egress guard gates every message to the frontier (fails closed), and the compile-feedback loop only ever sends back aliased source, never runtime output that might contain real content. Your documents never leave the Mac, and never reach the frontier model. See the walkthrough to try it, or Enclave for the full design.
Want to see it concretely? Dissect a real run — the exact prompt sent to the frontier model, the Mojo program it wrote, and the raw sentinel-delimited output that program printed, for one question over the synthetic demo vault. Or explore the whole system as an interactive architecture diagram ↗.
Categories, by rule — not by guesswork
"How much did I spend on my phone bill?" used to mean asking the local
model, transaction by transaction, "is this a phone carrier?" — slow, and
occasionally wrong in spectacular ways (a stray account number once read
as a phone number turned a phone bill into $224,303). Now
categories are deterministic rules, matched once when
your statements are indexed — so a category answer is a fast, exact filter
over stored tags, with no per-transaction model call.
phone, travel, restaurant,
groceries, health) plus anything you add to
categories.txt — tags every transaction once at index
time, with no model call. So a category answer is a fast, exact filter
over stored tags. The frontier model is told only the tag names
(the vocabulary it can filter on) — never your keyword rules, which hold the
merchant strings you typed and never leave the Mac.
A registry you can edit
The registry ships with sensible built-ins — phone,
travel, restaurant, groceries,
health — and is just a text file you own at
~/.config/millfolio/categories.txt. Add your own categories,
or extend a built-in, then re-index:
# ~/.config/millfolio/categories.txt — <tag> = keyword, keyword, …
pets = chewy, petco, the vet
subscriptions = netflix, spotify, hbo max
phone = my local carrier # extends the built-in phone tag
Matching is case-insensitive and multi-valued — an
airport restaurant can be both travel and
restaurant. And because the rules are deterministic, a
credit-card payment with a long account number is never mistaken
for a phone bill: it matches no carrier keyword, so it gets no tag.
What the model sees — names, not rules
At query time the frontier model is handed the available tag names — the vocabulary it's allowed to filter on, including your custom ones — and writes a program over the already-tagged transactions:
from vault import *
def main() raises:
var total = 0.0
for f in manifest():
for t in transactions(f.alias): # exact, reconciled, already tagged
if t.direction == "debit" and "phone" in t.tags:
total += t.amount
print_answer("You paid " + money(total) + " on your phone bill.")
The keyword rules — which hold the merchant strings you typed —
stay on your Mac; only the tag names ever cross to the
cloud, and the egress guard gates that message
like every other. Anything the rules don't name still falls back to the
on-device ask_local path shown
above.
AI rules — the fuzzy tail, materialized once
Some categories resist keywords. "Is this a gym?" can't be spelled as a
merchant list, so a category can instead be an AI rule —
a plain-English yes/no question the on-device model answers per
transaction. Write it with a : instead of an =:
# an AI rule — <tag> : <question>, answered on-device
gym : is this a gym or fitness membership?
A model call per transaction is far too slow to run at query time, so an
AI rule is materialized once and cached into the same
.tags column the keyword rules write — after which "how much
on the gym" is the identical fast, exact filter. The tricky part is knowing
what's already been computed: millfolio keeps a tiny per-rule
completion marker keyed on a monotonic
insertion generation — so a back-dated statement you import months
later is correctly seen as new work, and a true negative ("not a gym") is
classified exactly once, never again. Add a rule, or a new
statement, and only the genuinely-new transactions are ever re-run.
It's incremental and observable: a materialization panel
in the Tags tab shows a per-rule progress bar, a ready/pending badge, and a
Materialize now button, and a between-questions worker chips away
in the background (pausable). Until a rule is fully materialized it's held
back from the model's tag vocabulary — so a half-computed gym
filter can never quietly report "$0 on the gym". The verdicts are computed
by the local model; nothing about them leaves the Mac.