TRON
A modular security-orchestration platform for my own self-hosted infrastructure — AI recommends, a deterministic policy engine decides.
What it is
A security-orchestration layer that sits on top of the tools already watching my self-hosted infrastructure. It doesn’t replace them. It reads what they see, normalises it into one common shape, works out what is actually going on, and decides what — if anything — should happen next.
Today it runs continuously in production, and it has never taken an action. That is not a gap in the build. It is the point of it, and the rest of this page explains why.
Why it exists
I wanted AI to help me make sense of security signal from my own infrastructure without ever giving an AI the authority to act on it. Most tooling marketed as “AI security” blurs that line — a model both interprets the situation and triggers the response. TRON exists specifically to keep the line sharp, and to make the sharpness testable rather than promised.
The one rule
A language model can recommend an action. It never decides one.
A deterministic Policy Engine — plain rule evaluation, no model anywhere in the loop — makes the actual call, every time. Reasoning proposes; Policy disposes. Everything else in the system is downstream of that sentence: the module boundary, the fail-closed loaders, the approval protocol, the audit trail. Each stage exists so that rule can be enforced mechanically rather than trusted.
The pipeline
One fixed sequence. Every stage has exactly one job, and — more usefully — an explicit list of things it is not allowed to do.
| Stage | Its one job | What it may never do |
|---|---|---|
| Detection | Modules turn tool-specific alerts into one canonical event shape | Interpret severity, or decide anything |
| Normalisation | Give every event a stable identity | Carry volatile observational fields into that identity |
| Deduplication | Collapse repeats of the same underlying event | Discard evidence — the original is still audited |
| Rules | Resolve known patterns deterministically | Call a model, or act |
| AI Analysis | Classify and recommend on the unresolved remainder | Execute, set the final risk tier, or change policy |
| Policy | Decide, deterministically | Consult a model |
| Approval | Get a human decision for anything above Tier 1 | Treat an inbound message as authority without validating it |
| Execution | Run only what Policy explicitly authorised | Invent an action, or run a stale one |
| Audit | Record what happened | Fail silently, or be optional |
Nine stages, where the original design had seven. Nothing was bolted on: correlation split into deduplication and deterministic rules once it was clear those are different jobs with different failure modes, and reasoning split into rules and AI analysis so the model-free path could be the default rather than the fallback. Approval is the one genuinely new stage, added when it became obvious that “decide automatically” and “decide, then ask” are not the same permission.
Modules run as subprocesses communicating over line-delimited JSON, so the core never imports a module’s code and a module can be written in any language. There are three today — two sensors and one notification/approval provider. The core contains no module-specific branching at all; a test greps the source to prove it, because “we’ll keep it generic” is a promise and a failing test is a fact.
Autonomy tiers
| Tier | Meaning | Live today |
|---|---|---|
| 0 | Observe — log and analyse, change nothing | Yes |
| 1 | Safe autonomous — reversible, small blast radius, rate-limited | No |
| 2 | Approval required — a human decides, out of band | Built, validated end-to-end, not enabled |
| 3 | Manual only — storage, secrets, core networking; TRON may only propose | By design, permanently |
Fail-closed, everywhere
If the policy file is missing or invalid, TRON does not fall back to a default policy — it drops to a hardcoded Observe Only state. If a rule can’t be loaded or validated, that rule doesn’t run on a best-effort guess; it refuses to apply. If a signal is unclear or degraded, the answer is “don’t act,” not “act anyway.”
Policy lives on a read-only mount, enforced by the operating system rather than by application code politely declining to write. The version recorded against every decision is a hash of the policy that was actually loaded, not the one someone believes is deployed.
Paying for the AI stage before switching it on
The reasoning stage is the only place a model is involved, and the only place with an open-ended bill attached. Before a single real call was made, TRON ran a shadow mode that measured exactly what would have been sent: how many events reached that stage, payload sizes, estimated tokens, why the deterministic rules hadn’t already resolved them, and how bursty the traffic was. Zero model calls — enforced by an invariant, and by a test that fails if the calling code appears at all.
When the real stage did go live, it went live with a hard monthly cap, a daily cap, a payload-size limit and its own circuit breaker independent of the rest of the system, behind a separate feature gate. That ordering was deliberate: measure the cost of a stage before you can be surprised by it.
Human approval
Tier 2 exists because some actions are worth taking but not worth taking automatically. TRON asks over Matrix and waits.
The approval record is durable and crash-safe: an approval survives a restart, and the state machine can always resolve what was in flight rather than guessing. Trust is one-way — TRON sends, and an inbound message is never treated as authority until it has been validated as a genuine response to a specific pending request. Before anything is executed, the whole decision is revalidated from scratch; an approval granted against conditions that have since changed is not a licence to act on stale reasoning. Approvals expire on a TTL, checked on every sweep of the main loop.
Invariants as a testable artefact
Forty-five numbered invariants describe what the system must never do. They aren’t documentation — most are enforced by tests that fail if the property is broken, including structural ones like the grep test above. When a checkpoint discovers a new class of mistake, it usually ends with a new invariant rather than a new comment.
What real integration exposed that tests did not
Every stage was built with tests first and verified in a local container before deployment. That caught a great deal. It did not catch these, and each one changed the system rather than being patched over.
Module health was never persisted. The registry tracked healthy/degraded state correctly, and every test agreed, because every caller up to that point read that state inside the same process that wrote it. The orchestration daemon was the first component to put two genuinely separate processes — the running loop and the operator CLI — behind one shared database file, and the state simply wasn’t there. The assumption wasn’t wrong when it was made; it stopped being true the moment the process topology changed. The fix persists health properly, and the lesson is that a test suite in one process can’t observe an assumption about processes.
An upstream countdown field leaked into event identity. One detection source includes a remaining-duration value that ticks down on every read. It sat inside the part of the canonical event used to derive identity, so deduplication saw a brand-new event on every sweep of the same underlying alert. Fixtures never showed it, because a fixture’s fields don’t change between reads — only a live source does. The fix was not to special-case that field but to draw a general line: identity-stable data on one side, volatile observational data on the other, documented in the schema itself, with the other sensor audited against the same rule.
An external session lifecycle sat outside TRON’s assumptions. The notification path assumed a long-lived credential. The credential had been issued to an interactive client session that rotates it in the background as a normal part of its own lifecycle — so TRON’s copy silently became invalid mid-run, taking out both the send and receive paths at once, twice. The code was correct about everything it controlled and wrong about something it didn’t. The resolution was to stop borrowing an interactive session and give the service its own, and to treat “an external system may change credential state without telling us” as a failure mode to design for rather than an incident to recover from.
Correct code with no caller. The proactive expiry sweep for pending approvals existed and was tested, but nothing in the running loop ever called it. A separate per-approval path did expire an approval, but only when an inbound decision arrived naming it — so an approval whose notification never reached anyone received no decision, and could sit pending past its deadline indefinitely. The path that could have caught it was the one it could never trigger. Found by asking what would actually expire one specific stuck record, then tracing the call path, which is its own argument for periodically reading the system.
Where it actually stands
Running continuously in production on my own infrastructure: the orchestration loop, both detection modules, deduplication, the deterministic rules engine, real AI analysis under budget, the policy engine, Matrix notifications, and the full approval protocol — validated end-to-end against a real, controlled test event, which produced exactly one pending approval and stayed idempotent across subsequent sweeps.
The executor is built and gated off. No action has ever been executed against live infrastructure. The action ledger reads zero, and every stage that moved the system closer to being able to act required its own explicit, separate go-ahead. 644 automated tests pass.
Trade-offs and limits I know about
- The control plane shares a failure domain with what it watches. One host failure takes out both the monitored services and TRON itself. Accepted knowingly rather than solved with redundant hardware; recorded as a requirement that a control plane should eventually sit outside the blast radius of the thing it monitors.
- The executor is a single service. Blast radius is bounded by allowlists and capability approval, not by process isolation between actions. A deliberate MVP simplification, not an oversight.
- The thresholds aren’t calibrated. What counts as a known pattern versus something worth escalating still needs more real traffic than it has seen. Guessing the numbers now would produce confident-looking values with nothing behind them.
- Detection is narrow. Two sensors is a real starting point, not broad coverage.
What’s next
Network intelligence: a router/firewall and local DNS joining as modules through exactly the same mechanism the first two used — DNS as a sensor for device behaviour, the firewall as a restricted enforcement point. Core network configuration stays Tier 3, manual only, permanently.
Before any of that, calibration from real data, and the slow part: earning enough confidence in Observe to justify letting anything move to Tier 1.