TriOnyx Mail Subsystem — Design Specification¶
Status: draft v0.2 · consolidating design discussion Scope: agent access to mail, with credential isolation and human-gated mutation
Update (2026-08-04). Since this draft was written, TriOnyx replaced the FUSE driver with per-agent git repositories and kernel bind mounts. This changes some of the spec's assumptions: agent-visible mounts are now literal git-repo bind mounts (the agent's own repo read-write at
/workspace, shared repos at/repos/<name>viarepos_read/repos_write), and the/agents/<name>/**FUSE default write path injected bySandbox.build_fuse_policy/1no longer exists — an agent's memory files live directly in its own repo (e.g./workspace/NOTES.md). References in this document to the FUSE view daemon,fs_read/fs_writefields, and the FUSE D-state hazard describe the retired mechanism; the body is left unedited as the design record.Changes in v0.2. Added §14, mapping the design onto what TriOnyx runs today and recording two invariant violations that exist in the current implementation. Resolved §13.1 (materialization cost) by arithmetic rather than deferring it. Corrected the view permission model (§6.2), tightened the handle security claim (§5), added thread expansion to view construction (§6.1, §11.4), qualified the per-message process cost claim (§7.4), promoted the fd-inheritance transport problem from open question to requirement (§4, §13.4), reordered §7.5 to lead with parser containment, and reclassified cross-run laundering (§3, §13.3) from a risk this design avoids to one it must close.
1. Purpose¶
Give an LLM agent useful access to a mailbox without giving it the mailbox.
Concretely: the agent should be able to triage, extract, and draft, while the system guarantees by construction — not by policy evaluation — that it cannot read outside its assigned scope, cannot destroy mail, and cannot send without approval.
The design target is a system where the security properties are topological facts rather than assertions checked at runtime. "The agent cannot delete mail from the server" should be true because no code path exists, not because a rule denied it.
2. Invariants¶
These are load-bearing. Every design decision below traces to one of them.
I1 — Credential isolation. Credentials live in exactly one process. That process never parses attacker-influenced input. No process the agent can reach holds, or can reach, a credential.
I2 — Scope is fixed at spawn. An agent's readable scope is determined before it starts and cannot widen during a run. There is no runtime policy evaluation on the read path.
I3 — Single channel per boundary. No pair of coupled channels with shared lifecycle. Either one channel, or two fully independent planes joined only by durable identity.
I4 — Unix-composable.
Standard tools (grep, find, jq, xargs, mblaze, notmuch) work against
agent-visible surfaces without adaptation.
I5 — Backend-agnostic. IMAP is the first backend, not the model. Protocol specifics are confined to adapters.
I6 — Effects are proposals. The agent never mutates remote state. It proposes; a privileged component applies.
I1 and the cross-run corollary of I2 are both violated by the implementation running today. See §14.
3. Threat model¶
Primary adversary: message content. Any message body, header, filename, or attachment is attacker-controlled and reaches a model. Prompt injection is assumed to succeed; the design bounds its consequences rather than preventing it.
Secondary: agent implementation bugs. The agent is assumed to be capable of issuing arbitrary syscalls within its sandbox.
Explicitly out of scope:
- Malicious compute host. (Considered and rejected — see §12.5.)
- Model output correctness. Containment bounds actions, not truthfulness. A hallucinated or injected summary that causes a human to act wrongly is not addressed by any mechanism here. Mitigated only by §7.6 grounding checks and by keeping source references attached to derived claims.
Known residual risks:
- The human as an exfiltration channel — an approval UI rendering an attacker-crafted URL that a human clicks.
- Cross-run laundering via persistent agent-writable state. This is an open
channel in the current system, not a hypothetical. The email agent appends
to
/agents/email/NOTES.mdand re-reads it at the start of every session; the news agent does the same withPREFERENCES.md. Mail content therefore reaches the next run's instructions. See §13.3 and §14.3. - Free-text fields in inter-stage schemas passing injection through a boundary intended to constrain it (§7.5).
4. Process topology¶
┌──────────┐ fd3/JSONL ┌───────────┐ protocol ┌────────┐
│ adapter │◄─────────────►│supervisor │ │ server │
│ creds,net│ │ trusted │ └────────┘
└────┬─────┘ └─────┬─────┘ ▲
│ writes │ spawns │
▼ ▼ │
┌──────────┐ ┌───────────────┐ │
│local store│───────────►│ view daemon │ │
│ (maildir) │ projects │ FUSE, read-only│ │
└──────────┘ └───────┬───────┘ │
│ mounts │
┌──────▼──────┐ │
│ stages │ │
│ untrusted │ │
└──────┬──────┘ │
│ writes │
┌──────▼──────┐ ┌──────────┐ │
│ spool │───►│ applier │───┘
└─────────────┘ └──────────┘
Credential holder: adapter only. Both the supervisor (to sync) and the applier (to mutate) reach the server through the adapter. Nothing else has network egress to the mail host.
Spawn direction is load-bearing. The supervisor spawns everything. Stages
never spawn the adapter. Under a shared uid, a parent can ptrace its child and
read its memory — so a design where the agent host spawns the credentialed
process (MCP's model) provides no isolation. Each component runs as a distinct
uid.
No sockets. All inter-process communication is over inherited file descriptors. The fd is the capability: there is no path to guess and no ambient authority. Per-run scoping requires no token, because the pipe is the token.
4.1 Fd inheritance does not cross a container boundary¶
The fd-as-capability property holds only while every component is a process on
one host, in one pid namespace lineage. TriOnyx spawns agents by opening a Port
on the docker binary (lib/tri_onyx/agent_port.ex:672). Fds inherited by that
Port land in the docker CLI process on the host — they do not appear inside
the container. The same is true of any deployment where a stage runs in a VM or
over ssh.
This is a requirement, not an open question. Containerized stages are the default deployment shape for TriOnyx, so the design must name a transport that preserves capability semantics across that boundary from the start. 9p is the likely answer — filesystem semantics over a single byte stream, already the mechanism behind virtio-9p and virtiofs — with fd inheritance retained as the fast path where components genuinely share a host. See §13.4 and §14.2.
Local store, not live network. The view is backed by locally synced mail, not
a live connection. This avoids the FUSE D-state hazard, where a daemon blocking
on network makes every process touching the mount uninterruptible and immune to
kill -9.
5. Handles¶
A handle is an opaque, per-run identifier minted by the supervisor:
h_<128-bit random, base32>.
The supervisor holds the mapping to backend identity — (folder, UIDVALIDITY,
UID) for IMAP, Email/id for JMAP, message id for Gmail API, path for Maildir.
Nothing downstream of the supervisor ever sees backend identity.
Handles do two jobs:
Security. The agent cannot construct a reference to a message outside its view. An injected instruction naming a Message-ID has nothing to bind to. This converts a class of injection from "possible, hopefully caught" to "not expressible."
State the guarantee precisely, because the weaker form is what usually holds:
the property is cannot reference outside the view, not cannot reference
outside what it was shown. Any stage that reads manifest holds every handle
in scope, and an injection inside one message can name another. The stronger
property requires that the stage never see the manifest — which is what §7.4's
per-message namespace delivers, and why that pattern is preferred rather than
merely tidy.
Portability. Handles are the backend abstraction seam. Backend identity churn (UIDVALIDITY invalidation, id format differences) is the supervisor's problem, not a correctness bug in every downstream component.
Handles are stable for the lifetime of a run and are not reused across runs.
6. The view¶
The view is a read-only filesystem materialized once per run, before any stage starts.
The mount is the ACL. There is no read-path policy engine. A message is present in the namespace or it is not. Nothing to evaluate, nothing to bypass, no rule-combination semantics to get wrong.
6.1 Construction¶
The supervisor evaluates the run's view.query against the local store, using
credentials, before the agent exists. Query terms include collection membership,
date range, tags, and DKIM-verified sender identity.
DKIM verification happens here, once, as a query term — not as a per-access
policy predicate. Note that From: is unauthenticated and must never be used as
a trust signal; only DKIM-aligned identity may appear in a query, and
verification failure is a distinct state that cannot satisfy an allowlist.
Thread expansion is a view-construction operation. Any run that drafts replies needs the conversation, and the conversation is routinely older than the run's date window. Resolving that inside a stage would require whole-set visibility and cross-run state (§13.3); resolving it here does not, because the supervisor is already credentialed, already holds backend identity, and already runs before the agent exists.
view.expand: thread pulls the full thread for every matched message. limit
applies to matched roots, not to the expanded set — otherwise a single long
thread silently evicts the rest of the scope. Expanded messages are minted
handles like any other and are indistinguishable downstream; the widening
happens strictly before spawn, so I2 holds.
6.2 Layout¶
/run/trionyx/<run>.<pid>/
├── mail/ # mode 0111 — traversable, not listable
│ ├── h_7f3a1c9e/ # mode 0555
│ │ ├── envelope.json
│ │ ├── body.txt
│ │ └── attachments/
│ │ └── faktura.pdf
│ └── h_2b8d40f1/
│ └── ...
├── manifest # newline-separated handles, supervisor-written
├── <input dirs> # read-only, from prior stages
├── <output dir> # the stage's only writable path
└── spool/ # present only if the stage may propose
Read granularity is expressed as which files exist, not as permissions.
A stage granted project: [envelope] gets a tree with no body.txt — not
filtered, not materialized.
The non-enumerability permission belongs on mail/, not on the handle
directories. mail/ is mode 0111: search permission resolves a known name
without granting the directory read that ls requires, so a stage can reach a
message whose handle it holds and cannot enumerate the rest. Handle directories
themselves are 0555 — a stage that legitimately holds a handle must be able to
list it, because attachment filenames are not predictable and attachments/
would otherwise be unreachable. Putting 0111 on the handle directory breaks
the attachment path while adding nothing: a stage that can traverse into
h_7f3a1c9e/ has already proven it knows the handle.
Where a stage should see the full set, it reads manifest. Withholding
manifest is what upgrades the §5 guarantee from cannot reference outside the
view to cannot reference outside what it was shown.
6.3 Cost model¶
Materialization is a hardlink farm, and it is cheap enough at every realistic scope that the read-path policy layer can be deleted outright rather than traded against. See §13.1 for the arithmetic.
7. Stages¶
A stage is a privilege boundary: one process, one principal, one mount namespace. Stages run in sequence and share nothing but directories passed forward.
7.1 Contract¶
The supervisor guarantees a stage exactly four things:
- Zero or more read-only input directories
- Exactly one writable output directory
- A capability set (intersection of requested and granted)
- An opaque configuration blob
The supervisor does not parse the configuration blob. This is what decouples stage types from the supervisor: adding a stage type requires no supervisor change, because the supervisor never understood any stage's config in the first place.
7.2 Manifest¶
Ships with the implementation, declares only shape:
# /usr/lib/trionyx/stages/grounding-check.yaml
stage: grounding-check
version: 1
exec: /usr/lib/trionyx/bin/grounding-check
inputs:
records: {keyed: handle, format: json}
source: {keyed: handle, format: mail}
outputs:
out: {keyed: handle, format: json}
capabilities: []
config_schema: ./config.schema.json
Because ports declare {keyed, format}, the supervisor can typecheck a pipeline
at load — output format matches next input format, keying is consistent, no
dangling ports — while understanding none of the payloads.
7.3 Capabilities¶
A manifest's capabilities list is a request, not a grant. The effective set
is requested ∩ run-granted ∩ site-granted, default deny. A third-party stage
cannot grant itself network access.
Capability values: net:<host>, exec, spool, bulk-read.
7.4 Keying¶
Filename is the handle, throughout:
mail/h_7f3a1c9e/ stage 1 reads
records/h_7f3a1c9e.json stage 1 emits
clean/h_7f3a1c9e.json validator emits
spool/h_7f3a1c9e.0.json proposal
The join is ls. Coverage is comm -23 manifest <(ls records/ | sed s/.json//).
Resumption is skip-if-exists.
The binding must come from the filesystem, not from record content. A
handle field inside a JSON record is attacker-reachable: a message can instruct
the extractor to emit a record naming a different handle. Therefore:
- The output directory accepts writes only at paths matching handles in scope.
- Downstream stages derive the handle from the path and discard any internal
handlefield. - Preferred: spawn one extractor per message, with exactly one message
directory and one writable path in its namespace, and no
manifest. Handle confusion is then unnameable rather than merely validated against, and the §5 guarantee reaches its strong form. It also yields natural parallelism and per-message timeouts.
Writes are tmp + rename() so a reader never sees a partial file. One file per
handle; an array if a message yields several records.
The cost of per-message isolation depends entirely on the isolation unit, and
the naive claim does not survive containers. One fork+exec is ~1 ms —
genuine noise next to 8B inference, and the reason this pattern is preferred.
One container is 0.5–1 s, which at a 500-message scope is 4–8 minutes of pure
overhead before any work happens. Since TriOnyx's current isolation unit is a
container (§14.2), per-message isolation needs a cheaper unit — a unshared
mount namespace per message inside one long-lived stage container is the
obvious candidate — or the run falls back to per-run isolation with the
path-derived-handle validation above as the weaker guarantee. Choosing the unit
is a prerequisite for §7.4's preferred form, not an implementation detail.
7.5 Stage types¶
Stages are not necessarily models. Known useful types:
| type | actor | purpose |
|---|---|---|
extract |
small model | raw content → structured records |
schema-validate |
deterministic | strict schema, length caps, Unicode normalization |
grounding-check |
deterministic | verify claimed values appear in source |
pseudonymize |
deterministic | entities → stable placeholders |
rehydrate |
deterministic | placeholders → entities (holds the mapping) |
classify |
small model | filter: emits fewer records than it reads |
parse-attachment |
deterministic | contains PDF/HTML/archive parsers |
reason |
large model | operates on validated structure |
draft-lint |
deterministic | recipient allowlist, URL inertization, attachment rules |
Three of these deserve comment.
Parser containment — the strongest case for the stage mechanism. PDF, HTML, and archive parsers are the worst memory-safety surface in a mail pipeline. As a stage — one message, no network, seccomp, timeout — a parser RCE buys the attacker a process containing one message and one writable path. The containment is total rather than bandwidth-limiting, which makes this a better justification for stages than tiering: it converts arbitrary code execution into a bounded loss, where tiering only narrows a channel.
Tiering. "The small model sees bodies, the large one doesn't" is true only because they are separate processes with separate namespaces. Inside one process it is a promise; across a stage boundary it is a mount table.
The security value of a tier is proportional to how constrained the emitted
schema is. {vendor, amount, date, invoice_no} with typed fields leaves an
injection almost nowhere to survive. A summary: string field passes it through
verbatim. Every free-text field costs some of what the boundary bought.
The pseudonymize/rehydrate bracket. The expensive model reasons over
VENDOR_1 and AMOUNT_3; the rehydrator holds the mapping and is the only
component that sees both sides. Placeholders that fail to round-trip are a hard
error, which incidentally catches invented entities. This delivers what
homomorphic encryption was reaching for, using two small deterministic programs.
7.6 Output-side stages¶
Untrusted output deserves the same boundary as untrusted input. Draft linting — recipient allowlist, URL inertization, no forwarded attachments, Unicode normalization — belongs in a deterministic stage the model cannot influence, not as a property the model is asked to respect.
On the display format: charset restriction addresses invisible characters
(zero-width, bidi overrides, tag block U+E0000–E007F, variation selectors) but
not the primary channel. [text](https://attacker/?d=…) is pure ASCII, and an
image reference is auto-fetching egress requiring no click. The rule is no
auto-fetch, no divergence between displayed text and target, URLs shown inert
and in full — which means not emitting markdown for untrusted content.
Normalize rather than restrict: NFC, drop Unicode category Cf plus bidi controls and tag block, keep letters and marks, flag mixed-script tokens per UTS #39. ASCII-only would mangle Norwegian content.
Retain raw bytes alongside the normalized form. Stripping is lossy, and the original may matter as evidence.
8. The spool¶
The agent's only write path. Append-only JSONL, one file per proposal:
The spool is simultaneously the queue, the audit log, and the approval UI. Every Unix tool works on it directly.
8.1 Applier¶
Runs credentialed, drains the spool, applies through the adapter.
- Proposals matching
apply.autoare applied immediately. - Proposals matching
apply.denyare rejected at ingest. - Everything else moves to
pending/for human approval.
There is no third mode visible to the agent. "Draft-only send" is an empty
auto list, not a feature.
Idempotency via nonce. Staleness handling: apply only if the message is still at the expected location, else reject — another client may have moved it.
Batching is inherent: forty archive proposals are one approval, not forty. This is a material improvement over the synchronous per-call approval the current system uses (§14.4), where the agent blocks on each mutation individually.
8.2 Verbs¶
Canonical, backend-independent:
| verb | notes |
|---|---|
collection.add |
destination allowlist |
collection.remove |
gate if removing the last collection |
flag.add / flag.remove |
|
trash |
distinct from collection.remove |
expunge |
typically denied outright |
draft |
writes to Drafts, no transmission |
send |
separate verb, separate approval |
Move is not a primitive. Under the collection model it decomposes into add plus remove, which have different risk profiles. It is also a read-escalation vector: an agent that can move a message out of an unreadable collection into a readable one bypasses scope in two steps. Since scope is fixed at view materialization (§6), this is closed by construction — the agent cannot name a message outside its view — but the decomposition should be preserved so the property survives any future relaxation.
9. Backend adapters¶
An adapter is an executable speaking the adapter protocol on fd 3. It holds one credential, has egress to one host, and is separately sandboxable.
9.1 Canonical model¶
Collections, not folders. IMAP and Exchange are one-folder-per-message; JMAP, Gmail, and notmuch permit many. Model on the superset and let IMAP be the constrained adapter — the reverse loses expressiveness that cannot be recovered.
Under FUSE, many-to-many maps onto hardlinks: three collections, three links,
nlink=3.
9.2 Capability descriptor¶
Adapters declare, and the supervisor refuses pipelines requiring capabilities the backend lacks:
multi_collection bool
server_search bool
atomic_move bool
push bool
id_stability stable | validity_scoped | positional
credential_scopable bool
Degradation must be explicit. Silently converting collection.add into a
move on IMAP is how a pipeline behaves correctly against a Maildir in dev and
destructively against Exchange in production. Unsupported operations are refused.
9.3 Sync cursors¶
Opaque. IMAP has HIGHESTMODSEQ, JMAP has state strings, Gmail has historyId,
Maildir has mtimes. The supervisor stores the blob and returns it; it must never
parse it.
9.4 Send asymmetry¶
Transmission is a separate transport per backend: SMTP for IMAP,
EmailSubmission for JMAP, an API method for Gmail. Credential separation is
nearly free with IMAP+SMTP, unavailable with JMAP (one session does everything),
and available with Gmail only via OAuth scope.
Where credential_scopable is false, "cannot send" must be enforced by shipping
a read-only adapter binary that lacks the code path — not by configuration.
9.5 Reference implementation¶
Maildir adapter first. It has no credential at all, which makes the entire stage machinery and pipeline typechecking testable without any secret existing anywhere. It also forces the capability descriptor to be real from day one rather than retrofitted when the second backend arrives.
In TriOnyx specifically this is unusually cheap and unusually valuable: it decouples the whole redesign from the live mailbox, so the new topology can be built and tested to completion before anything touches the running IMAP account (§14.5).
10. Wire protocol¶
Newline-delimited JSON on fd 3, leaving stdout and stderr conventional. This avoids the most common failure mode in stdio protocols: a linked library printing a warning to stdout and corrupting the frame stream.
Bulk content does not cross the wire. Message bodies and attachments are read from the view; the protocol carries handles and structured records only. Where bulk transfer is unavoidable, chunked base64 with bounded frames:
64 KB chunks keep lines under ~87 KB, bounding allocation while keeping every line valid JSON. The encode/decode cost is negligible; unbounded frames are the actual hazard. Any transfer over a few hundred KB is a design smell indicating work that belongs on the supervisor side.
Where a boundary is crossed that fd 3 cannot reach (§4.1), the framing is unchanged — only the carrier differs.
11. Configuration¶
Two files. Stage manifests (§7.2) ship with implementations; runs only wire.
11.1 Site actors¶
# /etc/trionyx/actors.yaml
extractor: {exec: "llama-server --model qwen3-8b", net: none}
reasoner: {exec: trionyx-llm, net: [api.anthropic.com]}
Actors are roles. Swapping the small model is a site-config edit, not a change to every run.
11.2 Example: receipt triage¶
run:
name: kvittering-triage
backend: maildir
when: "daily 07:00"
view:
query: "collection:INBOX and date:14d.. and not tag:triaged"
limit: 500
pipeline:
- uses: extract@1
actor: extractor
in: {mail: {from: view, project: [envelope, body]}}
with: {schema: kvittering}
- uses: grounding-check@1
- uses: reason@1
actor: reasoner
in: {records: {from: grounding-check.out}}
spool: true
apply:
auto:
- "collection.add -> Kvitteringer"
- "collection.remove -> INBOX"
- "flag.add -> seen"
rate: 200/h
deny: [expunge, trash, send]
The reason stage has no mail input at all — its entire input is validated
structure.
11.3 Example: digest, no write path¶
run:
name: deal-digest
backend: maildir
when: "daily 06:00"
view:
query: "collection:Nyhetsbrev and date:1d.."
pipeline:
- uses: extract@1
actor: extractor
in: {mail: {from: view, project: [envelope, body]}}
- uses: reason@1
actor: reasoner
in: {records: {from: extract.out}}
output: /var/lib/trionyx/digest/%d.md
# no apply block, no spool: mutation is unexpressible
11.4 Example: drafting, nothing auto-applies¶
run:
name: korrespondanse
backend: imap-work
when: manual
view:
query: "collection:INBOX and dkim:ge.com and date:7d.."
expand: thread # full conversation for each match; see §6.1
limit: 50 # applies to matched roots, not the expanded set
pipeline:
- uses: reason@1
actor: reasoner
in: {mail: {from: view, project: [envelope, body]}}
spool: true
- uses: draft-lint@1
with: {rules: [recipient-allowlist, urls-inert, no-forwarded-attachments]}
apply:
auto: []
deny: [expunge, trash, collection.remove]
expand: thread is load-bearing here rather than a convenience. Without it the
view contains a reply whose conversation is older than the date window, and the
stage would need whole-set visibility to reconstruct it — which is the deferred
primitive in §13.3. Resolving threads at view construction keeps this run inside
the design instead of requiring the deferred piece.
11.5 YAML hazards¶
Given that these files control security properties:
- Indentation is load-bearing. A mis-indented
deny:becomes a child ofauto:and the denylist silently becomes an allowlist. Validate against JSON Schema at load, fail closed on unknown keys, and print the parsed structure at startup so a misparse is visible rather than latent. safe_loadonly. Never a loader honouring type tags.- The Norway problem, literally. YAML 1.1 parsers read bare
no,on,off,y,nas booleans. Quote all collection names and tags unconditionally.
12. Rejected alternatives¶
Recorded with rationale, because the reasoning is the reusable part.
12.1 IMAP ACLs (RFC 4314)¶
Wrong axis. ACLs are (identity × folder) → static rights. Agent policy wants
(session × action × object × context) → decision. Inexpressible: age limits,
headers-but-not-bodies, destination-conditional moves, rate limits, per-task
narrowing. Also requires owning the server, and denial surfaces as an opaque
tagged NO — the worst possible signal for a model, which then retries and
rephrases.
12.2 IMAP proxy¶
Requires a full tokenizer, because literals ({1234}, {1234+}) mean naive line
filtering permits command injection via message bodies. Requires CAPABILITY
rewriting or clients attempt commands that are then rejected. Filtering messages
from responses desynchronizes client sequence numbering. Places a live parser
adjacent to credentials permanently, violating I1.
12.3 Wrapping existing CLIs¶
Viable and was the basis for the read plane, but insufficient alone: every such
tool is a full-capability client. Config files are inside the trust boundary
(auth.cmd executes shell commands; notmuch hooks execute on new). Value came
from process isolation, which the stage mechanism generalizes.
12.4 Runtime policy language¶
A five-dimensional actor × verb × source × target → mode matrix required a
relational constraint bolted on to prevent read-from-confidential →
send-to-external, and a schema-level restriction preventing sender predicates
from appearing as standalone grants. Both are symptoms of a language expressive
enough to express its own bugs. Replaced by §6: scope fixed at spawn, mount is
the ACL.
12.5 Homomorphic encryption¶
Does not address the threat model. FHE is semantics-preserving by construction — the injected instruction is encrypted too, processed identically, producing an encrypted request to exfiltrate. The model must understand the message to summarize it; if it understands it, the injection lands.
Separately: effects are plaintext. Something must decrypt to issue an IMAP command, and that component sees everything. FHE would protect the one segment already on local disk under a local uid.
Performance is also prohibitive — encrypted transformer inference remains research-scale, fighting memory explosion in encrypted activations, with CPU overheads around 10⁴. Headline latency claims generally encrypt a subset of operations and run the remainder in the clear.
Where the intuition was right: crypto works on the index layer (searchable symmetric encryption, PIR), not the reasoning layer. Confidential computing (SEV-SNP, TDX, H100 CC mode) is the deployable answer to untrusted compute, at single-digit percent overhead and a weaker threat model.
12.6 Two coupled channels for bulk transfer¶
Lifecycle ownership, shared mount namespace that can never be dropped, and partial-failure states where a control message references an already-reaped blob. The FUSE view is not this: it is a persistent independent plane joined only by handles, with no cleanup ownership question. If the mount dies, actions still work; if the pipe dies, reads still work.
13. Open questions¶
13.1 Materialization cost — resolved, materialization wins¶
Previously held as the question that decides the architecture. It does not, and the arithmetic is not close enough to justify measuring before proceeding.
link() on a warm dentry cache costs roughly 5–20 µs. Against that:
| scope | links | materialization |
|---|---|---|
500 (§11.2's limit) |
~1.5k | under 10 ms |
| 10,000 | ~30k | ~150 ms |
| 100,000 | ~300k | 1.5–3 s |
The largest column is an order of magnitude beyond any scope a mail agent plausibly operates on, is paid once per run, and is still negligible against a run that spends minutes in inference. There is no crossover at which dynamic policy evaluation earns back its complexity, so the read-path policy layer is deleted rather than traded against.
Two costs are worth a benchmark later, neither of which is architectural: cold
dentry cache on a large first sync, and inode pressure on filesystems with
per-directory link limits (ext4's 65,000-subdirectory ceiling bounds a single
flat mail/ directory, so shard by handle prefix above ~50k messages).
13.2 Collection change: local or upstream¶
Whether collection.add is a local view change (notmuch-style tags, reconciled
upstream in batch) or a direct upstream write. Tags-plus-reconciler gives most of
the triage value with the destructive path touched only in batch, under one
approval, and is a materially smaller system.
13.3 Cross-run state — a channel to close, not one to avoid¶
Dedup, threading, and cross-run diffing need to see whole sets, so they cannot be handle-keyed and cannot use set-difference for coverage. They are a different primitive.
More seriously, any persistent agent-writable store is an untrusted input to
every subsequent run. This design does not get to defer the question on the
grounds of having no such store — TriOnyx has one already (NOTES.md,
PREFERENCES.md; see §14.3). The question is therefore not "should we open this
channel" but "what replaces the channel that is open," and any answer has to
survive the fact that the existing agents depend on it for learned corrections.
Threading specifically is removed from this bucket by §6.1: resolved at view construction by the credentialed supervisor, it needs no agent-writable state.
13.4 Transport reach — reframed as a requirement¶
FUSE is kernel-local, and fd inheritance does not cross a container boundary (§4.1). Since containerized stages are TriOnyx's default rather than an exotic deployment, this is a constraint on the design, not a question about it. 9p provides filesystem semantics over a single byte stream and crosses machine boundaries — same Plan 9 lineage, already the mechanism behind virtio-9p and virtiofs. Likely resolution: 9p for the shape, FUSE as a local convenience mount.
What remains open is narrower: whether the capability property survives 9p intact, or whether crossing that boundary reintroduces a nameable endpoint that needs a token after all.
13.5 Errno as a channel¶
EACCES from mv tells a model nothing, so it retries and burns context. The
Plan 9 ctl idiom — write a command, read back a structured result — is the
known fix, but must be built deliberately rather than inherited.
Note this matters less on the applier path than it looks: a stale-rejection (§8.1) surfaces to a human, not to the model. It matters most where a stage touches the view directly.
14. Current implementation and migration delta¶
This design is not greenfield. TriOnyx runs a degenerate version of the same topology today, and the gap is small enough that the interesting content is the delta rather than the target.
| spec component | today |
|---|---|
| adapter | lib/tri_onyx/connectors/email.ex — IMAP + SMTP credentials |
| supervisor | gateway: AgentSupervisor, Sandbox, TriggerRouter |
| view daemon | fuse/ — tri-onyx-fs |
| stage | one agent container per session |
| spool + applier | BCP.ApprovalQueue + SendEmail / MoveEmail / SaveDraft |
| run config | workspace/agent-definitions/email.md frontmatter |
The pieces exist. What differs is that several of the properties the design treats as topological are currently assertions.
14.1 I1 is violated: the credential process parses untrusted MIME¶
email.ex holds the SMTP and IMAP credentials and performs the MIME parse —
parse_mime/1, extract_body/3, extract_attachments/1,
extract_filename_from_disposition/1. Attacker-controlled bytes are parsed in
the same address space, in the same OS process, as the mailbox password.
BEAM memory safety makes this materially less dangerous than a C PDF parser
would be, and no concrete exploit is claimed. But the invariant as written says
the credential process never parses attacker-influenced input, and that is
simply not the case. Splitting the parse out — into a parse-attachment-shaped
stage (§7.5) or minimally a separate uid — is the first concrete migration step,
and it is independent of everything else in this document.
14.2 The isolation unit is a container, which breaks two mechanisms¶
Agents are spawned by Port.open on the docker binary
(lib/tri_onyx/agent_port.ex:672). Two consequences:
- Fd inheritance does not reach the stage (§4.1). The adapter protocol needs a carrier that crosses the container boundary.
- Per-message isolation is unaffordable at container granularity (§7.4). A cheaper unit is a prerequisite, not a refinement.
Both are solvable, but they are load-bearing choices that have to be made before the stage mechanism is built, not discovered during it.
14.3 The laundering channel is already open¶
Sandbox.build_fuse_policy/1 injects /agents/<name>/** as a default write
path for every agent — deliberately, so agents can maintain memory files. The
email agent's definition instructs it to append corrections to
/agents/email/NOTES.md and re-read that file at the start of every session.
Note that fs_write in email.md lists only drafts/**; the write access to
NOTES.md comes from the injected default, so the definition reads as more
restricted than it is.
The result is a durable path from message content to the next run's instructions, which is precisely the cross-run laundering channel §13.3 describes. It is in production, it predates this design, and the agents rely on it. Closing it is a requirement of the migration and needs a replacement for learned corrections — plausibly a human-approved write, routed through the same spool mechanism as any other mutation.
14.4 Approval is synchronous and unbatched¶
SendEmail blocks the agent until a human approves or rejects, one call at a
time. The spool model (§8) replaces this with async batch approval, which is a
real improvement: forty archive proposals become one decision, and the agent
does not hold a session open waiting on a human.
14.5 Suggested order¶
- Maildir adapter (§9.5) — no credential exists, so the stage machinery and pipeline typechecking are testable in full isolation from the live mailbox.
- Split MIME parsing out of the credential process (§14.1) — closes the I1 violation, independent of the rest.
- Choose the isolation unit and the container-crossing transport (§14.2) — both block the stage mechanism.
- Materialized view replacing the policy-evaluating FUSE (§6).
- Spool and applier replacing synchronous approval (§8).
- Close the
NOTES.mdchannel with an approved-write replacement (§14.3).
Appendix A — Prior art¶
- Plan 9
upas/fs— mailbox as synthetic filesystem, messages as directories, actions via a control file. The read-via-namespace/act-via-ctl idiom is the direct ancestor of §6 and §13.5. - Maildir — one file per message, flags in the filename suffix, atomic delivery via tmp + rename. The substrate, and the source of the atomicity property in §7.4.
- mbsync
Sync Pull— direction-selective propagation. Makes "local damage cannot reach the server" a topology fact. - RFC 4314 ACLs — rejected (§12.1), but the rights letters are a useful checklist of what a mail actor can actually do.
- MCP stdio transport — the wire shape, minus the spawn-direction error noted in §4.