← back

2026-09-16 · engineering notes

The model drafts, the code decides

I built Mini ISE, a small zero-trust network access service, live at mini-ise.vercel.app. Simulated devices ask for access, a service answers allow, deny or quarantine, and admins manage the policies in a console where Claude turns plain English into a draft policy.

The interesting part is not the drafting. It is the line I drew underneath it: the model is never on the decision path.

The obvious design, and why I did not build it

The obvious design is to let the model decide. Hand it the device, the network, the time of day and the posture signals, and ask whether this request should be allowed. It would work. Modern models are good at exactly this shape of judgment, and the demo would be shorter.

Three properties of access control rule it out. Access decisions get audited, which means the same inputs have to produce the same answer next quarter that they produced today, and a model plus a changing prompt plus a version bump does not give you that. Access has to keep working when someone else’s API does not, and a hosted model is a dependency you cannot page. And device attributes are attacker influenced: a device name is a string an attacker controls, so putting it in a prompt hands them a channel into the component deciding whether they get in.

None of those are objections to the model being wrong. That is the part I keep coming back to. Even a model that answers correctly every single time fails the audit requirement, the availability requirement and the injection requirement, because those are properties of the architecture rather than of the answer.

Where the model actually earns its place

Writing policy is genuinely hard for people. You know the rule you want in English, and then you have to express it in fields, operators and an ordering that decides which rule wins. That translation is where admins make mistakes, and it is exactly what a model is good at.

So the model drafts. An admin reads the draft and approves it. From that moment the decision belongs to a Python rules engine walking an ordered list, first match wins. Every request at run time is answered by code a human being signed off on.

The gate is the product

A draft only becomes a policy if it validates against the policy vocabulary: known fields, known operators, known actions. Anything invented is rejected before it reaches a human, and the console explains the rejection in plain language instead of showing a schema error. Ask for something the engine cannot express, a catch-all for instance, and it comes back marked infeasible with a suggestion of what to ask for instead.

That gate matters more than the prompt. A prompt is a request; a validator is a rule. If the model drifts, gets swapped, or has a bad day, the worst case is a rejected draft rather than a policy nobody intended.

Two engines, one fixture

There are two implementations of the policy walk: Python in the cluster, and TypeScript in the browser so the site can demonstrate the engine without a backend. Two implementations of the same rules drift, and the drift stays invisible until you are standing in front of an audience.

One JSON fixture of policy cases is executed by both test suites. A change that lands on one side fails a test on the other. It is a cheap trick, and it is the only reason I trust the browser demo to behave like the real thing.

What I would tell someone starting this

The useful question in a system like this is not whether a model is smart enough to decide. It is which decisions you are permitted to hand it, given what you owe an auditor, an on-call engineer, and an attacker who reads your inputs. Answer that first and where the model goes becomes obvious.

Here the answer was drafting yes, deciding no. In a different system it would land somewhere else. What should not happen is deciding by default, because that version of the demo was easier to build.