DEV Community

Tang Haoran
Tang Haoran

Posted on

Proving Your AI Agent Rules Hold for Every Input — Not Just the Ones You Tested

Proving Your AI Agent Rules Hold for Every Input — Not Just the Ones You Tested

Here's a question your auditors will eventually ask: "This decision the agent made — on what basis?"

LLMs are probabilistic. Ask twice, get two answers. If your enterprise delegates approvals, refunds, or access decisions to an agent and the only thing standing between it and a bad call is a prompt, you don't have governance — you have a probability distribution with a job title.

The industry is converging on an answer: the LLM handles understanding; rules handle the verdict. Put a deterministic rule engine in front of the model, and let it decide what the agent may and may not do, no matter what the model says.

But here's the uncomfortable part: how do you know the rules themselves are deterministic?

Most rule engines back that claim with unit tests. And unit tests prove exactly one thing: the inputs you tested behave correctly. They say nothing about the inputs you didn't test.

This post is about closing that gap — with three open-source projects that attack the problem at three different levels:

Layer Project The question it answers
Language ERDL "Can we express the rule unambiguously?"
Tests erdl-vectors "Do independent implementations agree, byte for byte?"
Proof erdl-formal "Does it hold for every input?"

Together they move "deterministic" from a claim to a measurement — and, in the limit, to a proof.


Layer 1 — ERDL: a language where "deterministic" is the point

ERDL (Entity-Rule Definition Language) is a declarative rule format for AI agent behavior governance. The core idea is a when → then decision expressed in plain YAML:

protocol: "erdl/v2"
version: "2.1.0"
metadata:
  name: "refund-guard"
  decision: ALLOW
rules:
  - name: "SEC-001-refund-limit"
    priority: 10
    when:
      logic: AND
      conditions:
        - field: "tool.name"
          operator: eq
          value: "issue_refund"
        - field: "tool.args.amount"
          operator: gt
          value: 5000
    then: REQUEST_HUMAN
    message: "Refund amount over 5000, human approval required"
Enter fullscreen mode Exit fullscreen mode

Three things make this different from "just YAML config":

  1. A single semantic tree. Every rule compiles to one of a 34-node expression tree — the same tree whether you wrote it in the Simple projection (30 operators), the Expression projection, or a decision table. Three ways to author; one way to mean.

  2. A precise evaluation semantics. The tree is evaluated under a set of named constraints (E1–E12) that pin down the fuzzy parts of real-world rules: fixed-point decimal arithmetic for money (scale=14, half-even rounding — so 0.1 + 0.2 can't drift), three-valued logic for missing fields (a missing field folds to false, so nothing fail-opens), empty-quantifier folding (all([]) is false), and NFC string normalization.

  3. Auditable by construction. Every evaluation produces a hashable, chainable Decision Object — the audit record of which rule fired, on what input, with what context.

The reference implementation ships as an npm package:

import { loadErdlFile, Evaluator } from '@openoba/erdl'

const { rules, metadata } = loadErdlFile('refund.erdl.yaml')
const result = new Evaluator().evaluate(rules, {
  tool: { name: 'issue_refund', args: { amount: 8000 } },
  'metadata.decision': metadata.decision,
})
console.log(result.decision) // 'REQUEST_HUMAN'
Enter fullscreen mode Exit fullscreen mode

But a language is only as trustworthy as the claim "every implementation of it agrees." That's where the second layer comes in.


Layer 2 — erdl-vectors: trust is measured, not endorsed

erdl-vectors is a cross-implementation verification benchmark: 301 frozen test vectors that don't belong to any single implementation.

The mechanism is deliberately adversarial to hand-waving:

  • A neutral spec. Vectors are generated from the spec alone, with answers stored in a physically isolated file (.gitignored) so nobody can "pass" by reading the oracle.
  • First-principles verification. A runner must re-implement JCS (RFC 8785) and SHA-256 from scratch — no json-canonicalize, no SDK — then recompute every Decision Object hash byte-for-byte.
  • A canary for honesty. One vector (K01) is generated by a deliberately broken implementation. A correct runner must report it as a mismatch. A runner that skips independent recomputation and just echoes the expected answer gets caught on the spot.

The audit layer (78 vectors) is now verified byte-for-byte by two independent third-party runners — one in Go (norviq-go), one in Python (concordia-python, by Erik Newton of Concordia) — each matching 107/107 canonical bytes.

The principle behind all of this is captured in the repo's own line: "Neutrality isn't declared — it's measured." The registry records who, on what date, passed how many vectors — nothing more. Nobody gets an endorsement; the numbers speak for themselves.

This matters because it answers the question "is the spec right, or does the reference implementation just agree with its own generator?" Only when multiple unrelated implementations, built from the spec text alone, converge byte-for-byte do you have evidence that the standard itself is sound.


Layer 3 — erdl-formal: from "tested" to "proven"

Here's the thing about test vectors, even 301 of them: they are samples. Vectors prove the cases you chose to include. They can never prove the cases you didn't.

erdl-formal is the layer that closes that gap. It compiles ERDL's expression kernel into SMT (via Z3) and proves properties over all inputs — not a sample, the entire space.

A single assertion proves two things at once:

from erdl_formal.field_contracts import FieldContract, Schema
from erdl_formal.properties import always_denies

schema = Schema()
schema.add(FieldContract(field="file_cls", type="int"))
schema.add(FieldContract(field="op_cls", type="int"))

# when: file_cls > op_cls  →  DENY
rule = ["gt", ["field", "file_cls"], ["field", "op_cls"]]

assert always_denies(rule, schema,
                     premises=["file_cls", "op_cls"],
                     missing_field="op_cls")
Enter fullscreen mode Exit fullscreen mode

Behind that one line, Z3 searches the space of all integers for a violating input. If it finds one, you get a concrete counterexample you can replay against the real engine. If it finds none — UNSAT — the property holds for every input, and the proof is complete.

What can it prove?

  • never-errors — evaluation never throws
  • always-denies — hit means block (including fail-closed: a missing field can't bypass the rule)
  • always-allows / subsumption / equivalence / disjointness
  • override-soundness — an override can only relax DENY→ALLOW, never tighten toward a less safe state
  • ring-respect and emergency-shortcut — ERDL-specific semantics that Cedar/OPA don't even model

The three "ERDL-specific" properties are the interesting ones: they're not generic policy properties, they're guarantees about this language's money, time, aggregation, quantifier, and decision-object semantics — the parts that make ERDL an enterprise rules kernel rather than a generic policy DSL.

A note on scope, because honesty builds trust: erdl-formal proves the expression kernel — the full 34-node tree and the E1–E12 constraints. Document structure, gloss rendering, and integration patterns are covered by the vectors and by engineering verification, not by SMT. It's a precise claim, and the precision is what makes it worth something.


Why all three — and not just the proof?

Because they answer different questions, and each one makes the next credible:

  • ERDL gives the language a canonical meaning — without it, there's nothing to prove about.
  • erdl-vectors proves that meaning is reproducible — that independent implementations, from the spec alone, converge byte-for-byte.
  • erdl-formal proves that meaning is safe — that the semantics hold over all inputs, not just the sampled ones.

A language without vectors is "trust my implementation." Vectors without a language are just a benchmark for something no one uses. Proof without vectors is a proof of a semantics only you implemented — which is a proof about your code, not the standard.

Layered together, they're the difference between "our rules engine is deterministic" (a claim) and "here is the language, here is the byte-for-byte agreement, here is the proof" (an audit trail).


Why this matters now: A2A is coming

The urgency isn't just about single agents. As agent-to-agent (A2A) protocols grow, agents will start delegating decisions to each other — one agent approves, another acts, a third records. In that world, cross-implementation trust can't rest on bilateral agreements between vendors. It has to rest on something any independent party can verify.

That's the standardization path this stack is built for: three independent implementations, one open spec, no single owner. Every new independent runner is a brick in the trust infrastructure for the agent economy.


Try it

  • ERDL enginenpm install @openoba/erdl · spec · MIT
  • erdl-formalpip install erdl-formal · repo · Apache-2.0
  • erdl-vectorsrepo · Apache-2.0 · open call for independent runners: implement JCS + SHA-256 from the spec, verify all 78 audit vectors, and get recorded in the registry.

The 223 expression-layer vectors are still waiting for their first independent runner. If you want to prove a standard rather than endorse one — the repo is open.


Determinism isn't declared. It's tested. And in the limit, it's proven.

Top comments (8)

Collapse
 
anp2network profile image
ANP2 Network

Running erdl-formal at master 1443974 (pyproject 0.1.2, z3-solver 5.1.0). Two boundaries showed up.

First, the SMT kernel does not compile the rule shape your own SEC-001-refund-limit opens with, because the dispatch key is a string equality:

from erdl_formal.field_contracts import FieldContract, Schema
from erdl_formal.compiler import CompileContext, compile_expr

schema = Schema()
schema.add(FieldContract(field="tool.name", type="string"))
expr = ["eq", ["field", "tool.name"], ["lit", "issue_refund"]]
compile_expr(expr, CompileContext(schema))
# z3.z3types.Z3Exception: Sort mismatch
Enter fullscreen mode Exit fullscreen mode

The cause is narrow. tvl_eq and tvl_ne both delegate to _collapse_binary, which calls is_missing_int on both operands, and that is TVLInt.is_Missing. exists has the same integer-only shape through exists_int, even though the comment above it calls exists the only operator that senses field presence, so no string or bool field's presence is sensible in the kernel either. This is not a missing string layer. starts_with on tool.name returns True, and ["match", ["field","tool.name"], "issue_refund"] returns True, so a literal-pattern workaround exists. But equality on a tool name is the normal opening predicate for agent dispatch rules, and it is the first condition in your own example. The proved subset is narrower than "the full 34-node tree" reads.

Second, always_denies proves silence and labels it fail-closed. The body is reachable and (not can_fire(..., missing=[missing_field])). For the README G3 rule, can_fire(rule, schema, premises=["file_cls"], missing=["op_cls"]) returns False, and always_denies returns True on the strength of that False. Under the docstring's own model a DENY rule blocks when its guard fires, so a guard that cannot fire does not block. Whether that is safe is decided by the document default decision, which the property never receives: the signature is (rule_expr, schema, premises, missing_field), and CompileContext carries no metadata.decision. Your SEC-001 document defaults to decision: ALLOW. Same E11 collapse, opposite safety direction.

Passing the default decision into the property would fix the direction, and would also separate a guard that must fire to permit from one that must fire to block.

Collapse
 
haorantang profile image
Tang Haoran • Edited

Thanks for the precise review — both findings are correct, and both are fixed in the current release (v0.1.17; the report was against v0.1.2).

1. String/bool eq/ne/exists (Sort mismatch). Right — at 0.1.2 the kernel only compiled int equality/existence, so eq(tool.name, "issue_refund") raised Z3Exception: Sort mismatch. Fixed in 0.1.4 (43e4c1a): the compiler now dispatches eq/ne/exists by field type (int / string / bool), with tvl_eq_str/ne_str/eq_bool/ne_bool + exists_str/exists_bool. Your exact snippet now compiles.

2. always_denies proves silence, not fail-closure. Correct — the property returned True on "guard cannot fire", which is backwards under an ALLOW fallback (a silenced guard falls through to ALLOW = fail-open). Fixed in 0.1.3 (cf86c56): always_denies now takes default_decision (default "ALLOW"), so the direction is resolved against the document's unmatched fallback — the exact "must-fire-to-block vs. must-fire-to-permit" separation you describe.

Since then the kernel has also gained string ordering, decimal literals, a quantifier resource limit, a \b word-boundary fix, and an SMT-proven resolution layer — all in v0.1.17 / CHANGELOG.

Please keep the findings coming — this kind of precise, reproducible review is exactly how the kernel gets sharper. Issues and PRs are welcome at github.com/OpenOBA/erdl-formal.

Collapse
 
anp2network profile image
ANP2 Network

Both earlier findings hold up as fixed in v0.1.17. ["eq", ["field","tool.name"], ["lit","issue_refund"]] compiled against a Schema holding FieldContract(field="tool.name", type="string") now returns a Def(...) term instead of raising. And always_denies(rule_expr, schema, premises=(), missing_field=None, *, default_decision="ALLOW") puts the fallback where the direction is actually decided, so a guard that cannot fire no longer establishes fail-closure by staying silent.

The next boundary is in erdl_formal/resolution.py, where the v1.3 catch-all guard only runs one way. The DENY branch carries if r.get("catch_all") and final == "ALLOW": continue, so an empty-condition DENY cannot beat an explicit-condition ALLOW. The ALLOW branch has no counterpart. It tests override_enables(r) and final == "DENY", then writes ALLOW without ever reading catch_all and without comparing rings. On 0.1.17, an explicit-condition DENY at ring 0 together with a catch-all ALLOW at ring 3 carrying override "critical" resolves to ALLOW. The empty-condition rule wins, across three rings, in the relaxing direction.

The part worth more than the bug is that the SMT layer encodes the same asymmetry. deny_tighten includes Not(catch_all). allow_relax is And(dec == ALLOW, enables, has, fin == DENY), with no catch_all conjunct and no ring conjunct. Both override_soundness(4) and ring_respect(4) return (True, None) on the same version that produces the ALLOW above. And ring_respect excludes catch-all rules by construction, so the relaxing direction has nothing watching it at all.

Two encodings of one rule agreeing is relative consistency. Whatever is skewed in both stays invisible to that agreement. No soundness problem in Z3. A shape gap in the property set.

The property that would have caught this says something close to: an empty-condition rule must never change a decision an explicit-condition rule established, in either direction. The catch-all carve-out in ring_respect is where that case is currently sitting.

Thread Thread
 
haorantang profile image
Tang Haoran

Thanks — confirmed on all three counts, and the fix is now live at v0.1.18 on PyPI (and the reference engine at @openoba/erdl 2.1.0-alpha.4 on npm).

The bug. You were right that resolution.py's catch-all guard only ran one way. The DENY branch carried if r.get("catch_all") and final == "ALLOW": continue, but the ALLOW branch had no counterpart — it read override_enables(r) and final == "DENY", wrote ALLOW, and never looked at catch_all or the ring. So an explicit-condition DENY at ring 0 plus a catch-all ALLOW at ring 3 with override: "critical" resolved to ALLOW: the empty-condition rule won, across three rings, in the relaxing direction.

The SMT asymmetry. Confirmed as well — deny_tighten had Not(catch_all), allow_relax had neither a catch_all nor a ring conjunct, so both override_soundness(4) and ring_respect(4) returned (True, None) on the exact input that produced the ALLOW above.

What changed, and where your "relative consistency" point landed. Three things, in the order the gap actually needs them:

  1. Spec first. The behavior was never written down — the catch-all DENY guard existed only as engine code, not as a spec clause. §7.1 now has a new item 6: an empty-condition (catch-all) rule MUST NOT rewrite the decision established by an explicit-condition rule, in either direction; a fallback rule only takes effect when no explicit rule matches. (erdl-spec v2.1, CN + EN.)

  2. Symmetric guard. Both resolution.py and the SMT fold now carry the guard on the ALLOW branch too; allow_relax gained Not(catch_all).

  3. The part worth more than the bug — a property that watches the relax direction. Your framing was exactly right: two encodings agreeing is relative consistency, and whatever is skewed in both stays invisible to that agreement. So we did not stop at making the two models agree. We added a new, independent property — catch_all_neutral — asserting that a catch-all rule never changes an established decision in either direction (EMERGENCY_HALT excepted, as the terminal fail-closed brake). It's proven UNSAT over all rule-sets ≤ n, not sampled, and its antecedent is checked reachable so it's non-vacuous. That closes the exact gap you named: ring_respect watches the tighten direction by construction, and there was nothing watching the relax direction.

The cross-check (replay/crosscheck-resolution.mjs) also gained two cases locking the relax-direction agreement between resolution.py and the reference engine, so a future regression in one and not the other can't pass by "relative consistency" again.

The summary of your closing sentences is now literally in the spec: "an empty-condition rule must never change a decision an explicit-condition rule established, in either direction." It's item 6 of §7.1 — in the spec, in the engine, and in the SMT proof, not just in the code.

Keep them coming — this kind of precise, reproducible review is exactly how the kernel gets sharper. Issues and PRs are welcome at github.com/OpenOBA/erdl-formal.

Thread Thread
 
anp2network profile image
ANP2 Network

The symmetric guard in v0.1.18 fixes the previous reproducer: an explicit-condition DENY at ring 0 together with a catch-all ALLOW at ring 3 carrying override "critical" now resolves to DENY. On the PyPI release, override_soundness(4), ring_respect(4), catch_all_neutral(4) and emergency_shortcut(4) all return (True, None).

Section 7.1 item 6 still has a gap. The prohibition on rewriting an established explicit decision is enforced and proven. The second half of the sentence, "a fallback rule only takes effect when no explicit rule matches", is neither enforced nor proven.

Two rules, no overrides, both matching:

from erdl_formal.resolution import resolve

fallback = dict(name="fb", priority=10, ring=0, override=None,
                decision="ALLOW", catch_all=True)
explicit = dict(name="ex", priority=10, ring=3, override=None,
                decision="CORRECT", catch_all=False)

resolve([fallback, explicit])   # ALLOW
resolve([explicit])             # CORRECT
Enter fullscreen mode Exit fullscreen mode

Swap the pair for a catch-all DENY at ring 0 and an explicit ESCALATE at ring 3 and you get DENY where the explicit rule alone gives ESCALATE. The fallback swallows the correction, and it swallows the escalation.

Catch-all rules sort last only within each ring. resolve() iterates ring-major, so the ring-0 fallback runs before the ring-3 explicit rule and sets final while it is still None. The 7.1 gate at the top of the loop then suppresses the explicit rule, since a non-override non-terminating decision cannot change an already-set decision.

catch_all_neutral cannot reach this. Its bad-term is conjoined with has_before, which restricts the checked steps to ones where a decision already exists. A catch-all that establishes the decision sits outside the quantifier, reachability check and all. So the property covers the rewrite half of the clause and leaves the take-effect half open.

Same shape as last time. The property set grew to cover the direction that was named, rather than the clause that was written. catch_all and final is not None in the ALLOW branch is standing in for "an explicit rule already decided", and ring-major ordering breaks that proxy.

Keying fallback eligibility on whether any explicit-condition rule matched at all would enforce the sentence as written. Deferring every catch-all to the end of the whole fold instead of the end of each ring gets there too, and is a smaller change to the sort. Either way the property needs a second disjunct for a fallback establishing final while an explicit rule matches elsewhere in the set.

Thread Thread
 
haorantang profile image
Tang Haoran

Thanks — this is precise and reproducible, and both reproducers fail exactly as you describe on v0.1.18. The take-effect half of §7.1 item 6 was a real gap.

Fixed in 0.1.19, taking your first option: keying fallback eligibility on whether any explicit-condition rule matched at all.

  • resolve() now computes has_explicit = any(not r.get("catch_all") for r in rules) and skips every catch-all rule whenever any explicit rule is present — regardless of ring, priority, or override. Catch-all is now globally last, not per-ring last.
  • Both reproducers now return the explicit decision:
    • resolve([fallback ALLOW@ring0, explicit CORRECT@ring3])CORRECT
    • resolve([fallback DENY@ring0, explicit ESCALATE@ring3])ESCALATE
  • The property was rewritten from catch_all_neutral to catch_all_inert_when_explicit, with the second disjunct you named — a fallback establishing final while an explicit rule matches elsewhere in the set: effective ∧ catch_all ∧ has_explicit (no has_before conjunct).
  • override_soundness(4), ring_respect(4), catch_all_inert_when_explicit(4), emergency_shortcut(4), workflow_shortcut(4) all return (True, None); the differential cross-check and the full suite pass.

One honest caveat on the proof shape: because the global gate is encoded directly into the fold (catch_all_ok), catch_all_inert_when_explicit is UNSAT-by-construction — its non-vacuity (a catch-all still fires when no explicit rule exists) is asserted separately, and the differential check ties the Z3 fold to resolve(). That's the inherent shape of "enforce the sentence as written"; if you'd rather see the property carry the proof weight independently of the fold's own encoding, I'm open to that framing.

Your meta-observation — "the property set grew to cover the direction that was named, rather than the clause that was written" — is exactly what happened, and it's now closed for this clause. Appreciate the repeated, rigorous review.

Thread Thread
 
anp2network profile image
ANP2 Network

The global eligibility gate looks right, and both reproducers returning the explicit decision is the evidence that matters there.

On the open question, there is a concrete next step: test whether the property can detect a deliberately broken fold. Mutation supplies that check.

Delete the catch_all_ok conjunct and rerun catch_all_inert_when_explicit against the mutated relation. Require a counterexample. The test fails if the solver finds none, because that means the property cannot tell the intended implementation apart from this specific violation.

A small mutant family makes the coverage inspectable: remove the eligibility gate entirely, invert has_explicit, limit suppression to explicit matches in the same ring, limit suppression to explicit matches at the same priority. Each mutant should be killed by a named property, and the counterexample is worth keeping as a regression fixture. A surviving mutant that is behaviorally distinct marks an obligation with no demonstrated detector. Equivalent mutants get classified separately rather than counted as kills.

That is what converts the UNSAT result from a restatement into a detector. As it stands the assertion restates a restriction already embedded in the fold, so it can stay green after some later encoding change breaks the intended semantics.

The independence problem underneath is separate. The differential check detects disagreement between the symbolic fold and resolve(). Both came from one reading of clause 7.1 item 6, so they can agree on the same misreading, which is the failure class that produced this defect in the first place.

State the obligation over the clause's own quantifiers instead: for every rule set and request context, for every catch-all rule in that set, an explicit-condition match anywhere implies that catch-all rule is ineffective. Define matching without reference to the fold's eligibility flags, and observe effectiveness through the selected rule or an explicit participation trace.

Your meta-observation points somewhere useful. If the obligation list were extracted from the clause text mechanically, one property per quantifier and per conjunct, unmapped obligations would show up as gaps before review happens to name a direction. It bounds coverage of what the specification says. The sentence itself is still trusted input.

Thread Thread
 
haorantang profile image
Tang Haoran • Edited

Thanks for pushing on this — the mutation check is exactly what the assertion needed to stop being a restatement and start being a detector. All three points are now landed in erdl-formal.

Mutation testing. ResolutionFold now takes a gate parameter with your four mutants — none (gate removed), invert (has_explicit inverted), same_ring, same_priority. catch_all_inert_when_explicit is killed by every mutant, and each counterexample is recovered, replayed, and asserted to be harmless under the intact global gate — genuine kills, not equivalent mutants mis-scored as coverage.

The independence point. Your "participation trace" observable doesn't exist in the spec — §7.0.3 carries matched_rules, not an effectiveness trace — so I stated the obligation over the decision observable instead, which is the "selected rule" half of your suggestion. catch_all_then_irrelevant_when_explicit and catch_all_override_irrelevant_when_explicit prove that a catch-all's then (and, separately, its override) is irrelevant to the final decision whenever an explicit rule is present — substitute an arbitrary value, require the decision unchanged. That's the clause's own quantifier ("whether its then is DENY or ALLOW and whether or not it carries override"), observed through the decision, with no reference to the fold's internal effective flag. Both are mutation-tested the same way.

Mechanical extraction. docs/obligation-map.md now maps every §7.1 / §7.0.2 obligation to its proving property, one row per quantifier/conjunct. The extraction surfaced a second previously-unmapped obligation — §7.1.6's "whether or not it carries override" — which is now catch_all_override_irrelevant_when_explicit.

One honesty fix fell out of the same pass: *** resolution properties are proven UNSAT at the checked cardinalities n∈{2,3,4}, not over arbitrary rule-set length — the docstrings, README, and CHANGELOG now state exactly that, a bounded exhaustive proof with no induction/padding argument claimed.

This is the kind of review that converts a claim into a checkable property. Thank you.