Designing Tri-State Logic for Safer and Cheaper AI Automation

Most rule engines start with a simple assumption: every condition evaluates to either true or false.

That sounds clean.

A rule matches, or it does not. An action runs, or it does not. The system remains deterministic, easy to reason about, and simple to implement.

Then real data arrives.

Some conditions cannot be decided from the information currently available. A rule may require data that has not been loaded yet. A later enrichment step may be needed. A semantic classifier may be required. A document may need to be parsed before a condition can be evaluated honestly.

A binary engine still demands an answer.

That is the problem.

When a system only supports true and false, it often collapses two very different situations into the same result:

false = this condition is definitively false
false = this condition cannot be decided yet

Those are not the same thing.

The first is a real decision. The second is missing information.

A production rule engine should not pretend to know something it does not know. That is especially important in AI-assisted automation, where expensive or risky steps should only run when the engine has enough evidence to justify them.

The architectural fix is simple but powerful: add a third state.

true
false
unknown

unknown is not an error. It is a valid evaluation result.

It means: the engine does not yet have enough information to make a final decision.

That small change makes the system safer, cheaper, easier to debug, and much more honest.


Why Binary Rule Engines Fail in Real Systems

A binary-only rule engine works well when all required data is available upfront and every condition is deterministic.

That is rarely true in production automation.

Real systems often evaluate rules in stages. Early stages may only have metadata. Later stages may load additional data. Expensive stages may call external services or AI models. Some conditions can be answered cheaply, while others require more context.

If the engine only supports true and false, uncertainty gets lost.

That creates four common problems.

First, false negatives appear. A rule may return false too early because the data needed to evaluate it has not been loaded yet.

Second, false positives appear. Weak early signals may trigger actions that should have required deeper validation.

Third, cost increases. Teams often compensate by calling expensive semantic stages too often because the engine cannot clearly distinguish “not matched” from “not enough context.”

Fourth, observability becomes poor. Logs may say a condition evaluated to false, but they do not explain whether the system was confidently negative or simply unable to decide.

The root problem is not bad prompts, bad regex, or bad if-statements.

The root problem is missing state semantics.

A rule engine needs a way to represent uncertainty explicitly.


Tri-State Logic as a Decision Protocol

Tri-state logic gives the engine a more accurate decision model:

true     → enough evidence exists and the condition matched
false    → enough evidence exists and the condition did not match
unknown  → not enough evidence exists at the current stage

The important part is that unknown is not treated as failure.

It is an orchestration signal.

It tells the system:

Do not run the action yet.
Do not reject the rule yet.
Acquire more context if allowed.
Then evaluate again.

That makes rule evaluation safer because side effects only run after a final decision.

It also makes the system cheaper because expensive enrichment steps are only executed for unresolved decisions.


A Generic Rule Engine Model

The rule engine itself should not know about invoices, support tickets, contracts, routing, fraud, compliance, or any other business-specific domain.

Those concepts belong to user-defined rules and user-defined conditions.

The engine should only know how to evaluate a rule tree.

A clean generic model can look like this:

export type EvalResult = 'true' | 'false' | 'unknown';

export type RuleNode =
  | ConditionNode
  | AndNode
  | OrNode
  | NotNode;

export type ConditionNode = {
  type: 'condition';
  conditionId: string;
};

export type AndNode = {
  type: 'and';
  children: RuleNode[];
};

export type OrNode = {
  type: 'or';
  children: RuleNode[];
};

export type NotNode = {
  type: 'not';
  child: RuleNode;
};

This structure is intentionally generic.

A condition is identified by conditionId. The engine does not care what that condition means. It may represent a metadata check, a text pattern, a classification result, a user-defined predicate, or a semantic decision.

The condition implementation is plugged in separately.

That separation matters because the rule engine remains reusable.

The user defines what conditions mean.

The engine defines how results combine.


Truth Tables for Tri-State Logic

Tri-state logic needs explicit operator semantics.

For AND:

any false     → false
all true      → true
otherwise     → unknown

For OR:

any true      → true
all false     → false
otherwise     → unknown

For NOT:

not true      → false
not false     → true
not unknown   → unknown

These rules preserve uncertainty without fabricating certainty.

A minimal implementation looks like this:

export function evalAnd(children: EvalResult[]): EvalResult {
  if (children.includes('false')) {
    return 'false';
  }

  if (children.every((x) => x === 'true')) {
    return 'true';
  }

  return 'unknown';
}

export function evalOr(children: EvalResult[]): EvalResult {
  if (children.includes('true')) {
    return 'true';
  }

  if (children.every((x) => x === 'false')) {
    return 'false';
  }

  return 'unknown';
}

export function evalNot(child: EvalResult): EvalResult {
  if (child === 'true') {
    return 'false';
  }

  if (child === 'false') {
    return 'true';
  }

  return 'unknown';
}

This is small code, but it changes the behavior of the entire system.

The engine no longer forces an artificial answer when the available context is insufficient.


Generic Evaluation Context

The engine should evaluate against a generic context object.

The context represents currently available information. It may grow over multiple stages.

export type EvaluationContext = {
  facts: Record<string, unknown>;
};

This allows user-defined conditions to read whatever facts they require.

For example:

export type ConditionEvaluator = (
  ctx: EvaluationContext
) => EvalResult;

The engine receives a registry of condition evaluators:

export type ConditionRegistry = Record<string, ConditionEvaluator>;

This keeps the core engine independent from business logic.


Generic Rule Tree Evaluation

The evaluator can now process any rule tree:

export function evaluateRule(
  node: RuleNode,
  ctx: EvaluationContext,
  conditions: ConditionRegistry
): EvalResult {
  switch (node.type) {
    case 'condition': {
      const evaluator = conditions[node.conditionId];

      if (!evaluator) {
        return 'unknown';
      }

      return evaluator(ctx);
    }

    case 'and': {
      const results = node.children.map((child) =>
        evaluateRule(child, ctx, conditions)
      );

      return evalAnd(results);
    }

    case 'or': {
      const results = node.children.map((child) =>
        evaluateRule(child, ctx, conditions)
      );

      return evalOr(results);
    }

    case 'not': {
      const result = evaluateRule(node.child, ctx, conditions);

      return evalNot(result);
    }
  }
}

This implementation has a deliberate behavior: if a condition is not registered, the result becomes unknown, not false.

That is safer.

A missing evaluator means the engine cannot decide. It does not mean the condition failed.


Example: User-Defined Conditions Without Domain Coupling

The engine remains generic. Users define the actual condition behavior.

A condition may check whether a fact exists:

const hasRequiredField: ConditionEvaluator = (ctx) => {
  return typeof ctx.facts.requiredField === 'string'
    ? 'true'
    : 'unknown';
};

Another condition may check a deterministic value:

const isPriorityHigh: ConditionEvaluator = (ctx) => {
  if (ctx.facts.priority === undefined) {
    return 'unknown';
  }

  return ctx.facts.priority === 'high'
    ? 'true'
    : 'false';
};

Another condition may depend on a semantic enrichment result:

const semanticMatch: ConditionEvaluator = (ctx) => {
  if (ctx.facts.semanticMatch === undefined) {
    return 'unknown';
  }

  return ctx.facts.semanticMatch === true
    ? 'true'
    : 'false';
};

The engine does not know what these facts mean. It only knows how to combine their results.

That is the correct separation.


Stage-Based Evaluation

Tri-state logic becomes most valuable when paired with staged evaluation.

A staged system starts with cheap context and only loads expensive context when needed.

The pattern looks like this:

Stage A: evaluate cheap facts
If result is true or false → stop
If result is unknown → enrich context

Stage B: evaluate with additional deterministic data
If result is true or false → stop
If result is unknown → call expensive semantic stage

Stage C: evaluate with semantic facts
Return final result

The engine itself stays pure.

The orchestrator handles enrichment.

That boundary is critical.

Evaluation should be deterministic and testable. Loading data, calling services, parsing documents, or invoking AI should happen outside the evaluator.


Generic Orchestrator

A generic staged orchestrator can look like this:

export type ContextEnricher = (
  ctx: EvaluationContext
) => Promise<EvaluationContext>;

export async function decideWithStages(
  rule: RuleNode,
  initialContext: EvaluationContext,
  conditions: ConditionRegistry,
  enrichers: ContextEnricher[]
): Promise<EvalResult> {
  let ctx = initialContext;

  let result = evaluateRule(rule, ctx, conditions);

  if (result !== 'unknown') {
    return result;
  }

  for (const enrich of enrichers) {
    ctx = await enrich(ctx);

    result = evaluateRule(rule, ctx, conditions);

    if (result !== 'unknown') {
      return result;
    }
  }

  return 'unknown';
}

This keeps the design clean.

The rule engine evaluates.

The orchestrator enriches.

The conditions interpret facts.

No layer does too much.


Why unknown Reduces Cost

The cost benefit comes from short-circuiting.

If a rule definitively evaluates to false early, there is no reason to call an expensive semantic stage.

If a rule definitively evaluates to true early, there may also be no reason to call an LLM.

Only unresolved decisions move forward.

That creates a cost-aware funnel:

cheap deterministic certainty first
expensive semantic evaluation last

This is especially useful in AI automation because many inputs can be classified or rejected using deterministic signals before any model call is needed.

The engine does not need domain-specific assumptions to support that.

It only needs to preserve uncertainty correctly.


Defensive Semantic Parsing

If an expensive AI or external semantic stage is used, its output should be treated as untrusted input.

The rule engine should not blindly trust free-form model text.

A generic semantic parser can use a strict contract:

export type SemanticGateResult = {
  match: boolean;
  reason: string;
};

A defensive parser may look like this:

export function parseSemanticGateOutput(
  text: string
): SemanticGateResult | null {
  const cleaned = stripMarkdownFence(text);
  const candidate = extractFirstBalancedJson(cleaned);

  if (!candidate) {
    return null;
  }

  try {
    const parsed = JSON.parse(candidate) as unknown;

    if (
      parsed &&
      typeof parsed === 'object' &&
      typeof (parsed as any).match === 'boolean' &&
      typeof (parsed as any).reason === 'string'
    ) {
      return {
        match: (parsed as any).match,
        reason: (parsed as any).reason.trim()
      };
    }
  } catch {
    return null;
  }

  return null;
}

A simple fence stripper:

export function stripMarkdownFence(text: string): string {
  return text
    .replace(/^```(?:json)?/i, '')
    .replace(/```$/i, '')
    .trim();
}

A balanced JSON extractor can be implemented like this:

export function extractFirstBalancedJson(text: string): string | null {
  const start = text.indexOf('{');

  if (start === -1) {
    return null;
  }

  let depth = 0;
  let inString = false;
  let escaped = false;

  for (let i = start; i < text.length; i++) {
    const ch = text[i];

    if (escaped) {
      escaped = false;
      continue;
    }

    if (ch === '\\') {
      escaped = true;
      continue;
    }

    if (ch === '"') {
      inString = !inString;
      continue;
    }

    if (inString) {
      continue;
    }

    if (ch === '{') {
      depth++;
    }

    if (ch === '}') {
      depth--;

      if (depth === 0) {
        return text.slice(start, i + 1);
      }
    }
  }

  return null;
}

If parsing fails, the orchestrator should not guess.

It should return a conservative result, log the failure, or keep the decision unresolved depending on the safety requirements.

For many systems, invalid semantic output should not trigger side effects.


Safe Finalization Policy

A tri-state engine must define what happens if the final result remains unknown.

There are three common policies.

The first is block:

unknown → do not execute action

This is safest for destructive or high-risk workflows.

The second is route to human review:

unknown → manual decision required

This is useful when missed opportunities matter.

The third is fallback:

unknown → apply configured default

This should be used carefully and explicitly.

The worst design is implicit coercion:

const shouldRun = result === 'true';

That silently treats unknown as false.

Sometimes that is acceptable as a final safety policy, but it should be named explicitly:

export function toActionDecision(result: EvalResult): boolean {
  return result === 'true';
}

Do not hide uncertainty accidentally.

Make the conversion visible.


Observability: Make Unknown Measurable

Once unknown exists, it becomes a powerful optimization signal.

You can measure:

unknown rate per rule
unknown rate per condition
stage where unknown was resolved
unknown → true ratio
unknown → false ratio
cost per resolved decision
latency per stage
semantic parser failure rate

This gives the system a feedback loop.

If a rule produces many unknowns at Stage A but almost all resolve at Stage B, deterministic enrichment is working.

If many rules reach Stage C, the earlier stages may need better conditions or better available facts.

If semantic parsing fails often, the output contract or prompt needs improvement.

Without unknown, all of this gets hidden inside misleading false results.


Testing Strategy

Tri-state engines are easy to test if the evaluator is pure.

Leaf condition tests should cover:

true
false
unknown

Group operator tests should cover mixed combinations:

AND(true, true)       → true
AND(true, unknown)    → unknown
AND(false, unknown)   → false

OR(false, false)      → false
OR(false, unknown)    → unknown
OR(true, unknown)     → true

NOT(true)             → false
NOT(false)            → true
NOT(unknown)          → unknown

Orchestrator tests should verify stage behavior:

Stage A resolves true → no enrichers called
Stage A resolves false → no enrichers called
Stage A unknown → Stage B called
Stage B resolves → Stage C not called
All stages unknown → final unknown policy applies
Malformed semantic output → no unsafe action

The most important safety test is this:

No side effect runs while result is unknown.

That is the behavioral contract.


Common Implementation Mistakes

The first common mistake is collapsing unknown into false too early. This destroys the diagnostic value of the third state and reintroduces premature decisions.

The second mistake is allowing actions to run before the rule reaches a terminal decision. unknown must not trigger side effects.

The third mistake is mixing IO inside condition evaluators. If a condition calls a network service or model directly, the evaluator becomes slow, expensive, and hard to test.

The fourth mistake is treating AI output as trusted structured data. It is not. It must be parsed, validated, and handled defensively.

The fifth mistake is making the engine domain-specific. A good rule engine should not contain business concepts. Those belong in user-defined conditions and context enrichers.


Final Takeaway

The most valuable state in a modern rule engine is often not true or false.

It is unknown.

That one state allows the system to acknowledge uncertainty honestly, defer expensive work intelligently, avoid premature side effects, and expose ambiguity as a measurable signal.

In AI-assisted automation, robustness does not come from pretending the system knows everything at the first evaluation step.

It comes from knowing when the system knows enough.

A binary rule engine can decide.

A tri-state rule engine can decide responsibly.

Von admin