Back to Blog
Architecture2026-10-09

The Case for Full Replacement: Why Managed Iddio Policy Doesn't Merge

Why the v2.2 Control Server replaces a device's local policy outright instead of merging it with the organization's, and how the daemon enforces that with one atomic pointer and two guards.

When a security tool grows from a single-user local daemon into something an organization manages centrally, the hardest question is not transport or identity. It is this: when an administrator pushes a policy to a developer’s machine, what happens to the policy that was already there?

In Pragmatism Over Purity we covered how the v2.2 Control Server talks to daemons, how devices enroll, and why an enrolled daemon fails closed. This post zooms in on the decision underneath all of that: an enrolled daemon’s policy is the server’s policy, in full. Nothing is merged.

The Obvious Answer Is Merging

The usual answer is inheritance. The central policy and the local policy are combined, and conflicts are resolved by a rule like “stricter wins” or “deny overrides”. It is appealing because it seems to give everyone something: the organization sets a floor, and developers can still tighten their own agent.

When we wrote down the options, there were three:

ModelWhat it means
1. Full replaceEnrolled → the server’s policy replaces the local one; local edits are locked.
2. Managed baseline, local can only be stricterBoth documents apply; a precedence engine decides the effective rule.
3. Managed + user-proposed exceptionsDevelopers request loosening; an admin approves.

We shipped Model 1.

The Merge Engine Trap

Model 2 sounds like a small extension. It isn’t.

Iddio’s policy engine evaluates one document. To evaluate two, you need a new piece of software that understands both: which rule wins when an admin allows kubectl exec in a namespace and the local file denies it? What does “stricter” mean when the two documents classify the same command into different tiers? If the admin denies all AWS writes outside one region and the local file is simply silent on AWS, is that silence a conflict, a gap, or an allow?

Each answer is defensible. Together they form a precedence graph, and that graph becomes a second policy language nobody wrote down, with its own edge cases and its own test surface. Worse, it lives in the daemon, on the hot path of every proxied request.

And it hides the answer to the one question an administrator actually asks: what is this machine allowed to do right now? With a merge, the dashboard can only show half of it. The other half is in a file on a laptop the admin can’t see.

Full Replace Reuses What Already Exists

Model 1 needs no new engine work at all, because the daemon already knew how to swap one policy for another. Every live reload of ~/.iddio/policy.yaml goes through the same handle the proxy reads on every connection:

// Handle wraps *Engine for lock-free reads. The proxy hot path calls
// Snapshot() once per accepted connection.
type Handle struct {
	ptr atomic.Pointer[Engine]
}

// Snapshot returns the active engine.
func (h *Handle) Snapshot() *Engine { return h.ptr.Load() }

// Store atomically swaps the engine.
func (h *Handle) Store(e *Engine) { h.ptr.Store(e) }

Managed policy is “just another writer to Handle.Store()”. The sync loop polls the Control Server, validates the published policy with the same loader the local file uses, stores the compiled engine into the handle, and persists the raw YAML to ~/.iddio/managed_policy.yaml (mode 0600) as last-known-good. A request that started a moment before the swap finishes against the old engine; the next one sees the new engine. There is never a half-applied state, because there is only ever one document.

Server-side, the Control Server checks every published policy against a faithful re-implementation of the daemon’s Document.Validate() invariants, so an admin cannot publish something an enrolled daemon would refuse to load.

Locking the Local File

Full replace only means something if the local file can’t quietly take back control. There are three ways it could, and each is guarded.

Edits over IPC. The CLI and desktop app write and reload policy through the daemon’s IPC socket. While the device is enrolled, both calls are refused before anything touches disk or the live engine:

func (h *daemonHandler) HandleWritePolicyFile(_ context.Context, yaml string) error {
	if h.managedModeActive() {
		h.auditLocalEditIgnored("ipc.write")
		return ipc.PolicyManaged(managedPolicyEditMsg)
	}
	// ... unmanaged path: validate, write, swap ...
}

The refusal is actionable rather than cryptic: “this device is enrolled and enforces server-managed policy; local policy edits are disabled — change the policy in the Iddio dashboard”.

Edits on disk. A developer, or any process with write access to their home directory, can still edit policy.yaml directly. The file watcher keeps running, but every event is checked against the enrollment flag per event, not just when the watcher starts, so it stays correct if a device enrolls mid-session. While enrolled, the change is ignored: the live engine is untouched, a warning explains why the edit had no effect, and a policy_local_edit_ignored entry lands in the audit log so an administrator can see that someone tried.

Boot. On startup an enrolled daemon loads its last-known-good managed policy before the proxy serves its first request, so a restart never briefly enforces the local file. If that stored copy is corrupt, the daemon logs it loudly and keeps whatever engine is live rather than reverting to local policy. The next successful sync repairs it.

There is one deliberate gap: a device that has just enrolled but has never synced has no managed policy yet. Rather than deny everything and break its tooling, it keeps enforcing its existing local policy, now frozen because local edits are already locked, and says so loudly in iddio status until the first sync lands.

What We Gave Up

Full replace has a real cost: a developer on a managed device can no longer tighten their own agent beyond what the organization set. Someone who wants their agent to refuse all production writes, even ones the organization permits, has to ask for that in the dashboard.

We think that is the right trade for v2.2. The point of managed mode is that the organization’s policy is authoritative and that the local user cannot loosen it. Model 1 guarantees that with code that already existed and had already been tested. Model 2 would have added a precedence engine to the hot path to serve a secondary goal.

The door isn’t closed. Server-side, policy is stored and versioned per profile, so a stricter-only merge can be added later without a schema rewrite. If it comes, it will arrive as an explicit feature with its own semantics, not as an emergent property of two files on a laptop.

Predictability Over Flexibility

Security policy is not a collaborative document. In a managed fleet, the property that matters most is that the administrator can answer “what can this machine do?” by looking at one place.

With full replace, what the dashboard shows is exactly what the daemon enforces: one document, swapped atomically, with every attempt to change it locally refused or ignored, and recorded in the audit log.

Try It Yourself

Iddio is open source. Deploy a zero-trust command proxy for your AI agents in minutes.