{"slug": "neurosymbolic-guardrails-for-ai-agents-answers-you-can-prove", "title": "Neurosymbolic guardrails for AI agents: answers you can prove", "summary": "Nearform engineers argue that pairing large language models with symbolic AI solvers can mathematically verify agent answers against written rules, citing New York City's MyCity chatbot, launched in October 2023, which told businesses they could go cash-free despite a local law requiring cash acceptance since 2020, and a 2024 Canadian tribunal ruling that held Air Canada liable for a refund its website chatbot had promised. The authors trace the approach to decades of formal verification, including NASA's 1997 model-checking of the Remote Agent software that found five errors before an unchecked sibling subsystem deadlocked during the 1999 Deep Space 1 flight experiment, and Intel's $475 million charge in 1994 over the Pentium division flaw.", "body_md": "### In October 2023, New York City launched MyCity, a chatbot to answer business owners' questions from thousands of pages of official guidance. Five months later, reporters at [The Markup](https://themarkup.org/artificial-intelligence/2024/03/29/nycs-ai-chatbot-tells-businesses-to-break-the-law) asked it whether a restaurant could stop taking cash. \"Yes, you can make your restaurant cash-free,\" it replied. \"There are no regulations in New York City that require businesses to accept cash as a form of payment.\" A local law had required exactly that since 2020.\n\nAnswers like that can carry legal weight. In 2024, [a Canadian tribunal ruled](https://www.americanbar.org/groups/business_law/resources/business-law-today/2024-february/bc-tribunal-confirms-companies-remain-liable-information-provided-ai-chatbot/) that Air Canada was responsible for a refund its website chatbot had promised, contrary to the airline's own policy.\n\nAt Nearform, we build agents on today's stack of large language models (LLMs), retrieval and evals, and for most questions a well-tested agent is good enough. When an answer has to follow a written rule, there is a more rigorous option, and it is older than the models it checks. [Symbolic AI](https://en.wikipedia.org/wiki/Symbolic_artificial_intelligence), the branch of the field built on explicit logic, has checked statements against rules for decades. While deep learning drew attention in the 2010s, logic-based tools were verifying chip designs and flight software.\n\nLLMs come from the other branch, the one that learns from examples. Can we pair that logic with an LLM, so that its answers are checked against the rules they are meant to follow? Translated into formal logic once, a written policy lets you check each LLM answer and agent action mathematically, and when one breaks a rule, the check names that rule and the values responsible.\n\n## Where symbolic AI went\n\nThe term “artificial intelligence” was coined in the proposal for a [1956 summer workshop at Dartmouth](https://ojs.aaai.org/aimagazine/index.php/aimagazine/article/view/1904), and two of the approaches that followed matter here. Symbolic AI writes knowledge down as explicit rules over symbols, so every conclusion traces back to rules and variables that a person can inspect. Neural networks learn from examples instead, a line of research that goes back to Frank Rosenblatt's perceptron in 1958. They spent decades in the background, until a deep network from Geoffrey Hinton's group in Toronto won the [ImageNet image recognition challenge](https://arxiv.org/abs/1409.0575) by a wide margin in 2012. Today's LLMs are built on that approach.\n\nLogic-based methods kept developing throughout, often outside anything labelled AI, in theorem provers, model checkers and solvers. A solver, the tool behind the examples below, takes a set of logical statements and works out whether they can all be true at once, returning either values that satisfy every one of them or a proof that no such values exist. Writing that logic took specialists, so formal verification was used where a mistake would cost more than they did. After Intel took a $475 million charge in 1994 for a [division flaw](https://en.wikipedia.org/wiki/Pentium_FDIV_bug) in its Pentium processors, the industry's use of formal verification for floating-point hardware rose sharply, and in 2003 the [Astrée](https://www.astree.ens.fr/) static analyser proved that the flight control software of the Airbus A340 could raise no runtime error.\n\nThe clearest comparison between checked and unchecked code comes from a spacecraft. In 1997, a [NASA team model-checked](https://ntrs.nasa.gov/api/citations/20000055731/downloads/20000055731.pdf) part of the Remote Agent, the autonomous software that later controlled the Deep Space 1 probe during a flight experiment, and found five errors. In 1999, while the Remote Agent was running the probe, a deadlock in a sibling subsystem that had not been checked stopped it, and the team reported that it was the same bug pattern as one it had found two years earlier.\n\n## What neurosymbolic AI is\n\nNeurosymbolic AI is the name for systems that combine neural networks with symbolic reasoning. Researchers have worked on the idea for more than twenty years, and a 2020 paper called it the [third wave of AI](https://arxiv.org/abs/2012.05876). The same year, Henry Kautz sorted the approaches into six types in a [lecture](https://onlinelibrary.wiley.com/doi/10.1002/aaai.12036) at the annual conference of the [Association for the Advancement of Artificial Intelligence](https://aaai.org/about-aaai/). This article uses his [Type 3](https://en.wikipedia.org/wiki/Neuro-symbolic_AI#:~:text=Neural%20%7C%20Symbolic) (written Neuro | Symbolic), in which a neural network and a symbolic system work on complementary tasks and interact through their inputs and outputs. The LLM writes the answer, and a solver decides whether it holds.\n\nLLMs change who writes the logic, a job that used to take specialists. An LLM can draft rules from a policy document, the policy owner can review them clause by clause, and a solver can then check every later answer against those rules. Amazon Bedrock Guardrails has offered a managed version of that flow, called [Automated Reasoning checks](https://docs.aws.amazon.com/bedrock/latest/userguide/guardrails-automated-reasoning-checks.html), since 2025.\n\n## How the check works\n\nThe check has three stages, shown in the diagram below, and only the first involves a person.\n\n*Rules are drafted and reviewed once per policy version, then every answer or agent action is checked against them.*\n\n1. Once per document version, an LLM translates the policy into named rules over a few typed variables, and the policy owner (or the subject-matter experts they assign, such as legal or compliance) reviews each rule next to the sentence it came from. Several translations of each sentence are compared, and test cases taken from the document catch translations that get a rule wrong.\n2. For every answer, the LLM restates what it claims on the same variables.\n3. The solver returns one of three verdicts:\n  - valid: the answer follows from the rules, and the rules that prove it are named;\n  - invalid: the answer contradicts a rule, and both the rule and the offending values come back;\n  - satisfiable: the rules neither prove nor refute the answer, and the solver gives one example case where it holds and one where it fails, which shows the missing fact that would decide it.\n\nIf the facts themselves contradict the rules, there is nothing to judge, and the check reports them as inconsistent instead.\n\nSatisfiable is the verdict a yes/no checker cannot express, and a common one in practice, because real questions leave facts out. Reporting it as either allowed or denied would be a guess, so the right response is to ask for the missing fact.\n\nThe more common way to check an answer today is LLM-as-a-judge, where a second model reads the answer and the policy and decides whether the answer complies. A [2023 study](https://arxiv.org/abs/2306.05685) of judges rating chatbot answers found that GPT-4 agreed with human preferences over 80% of the time, about as often as people agree with each other. It also measured biases, such as favouring the first of two answers or the longer one. A judge can handle vague wording such as \"reasonable\", which logic cannot, but its verdict is another model output and can be wrong in the same ways as the answer it checks. The solver's verdict follows from rules the policy owner has reviewed, is the same every time for the same logic, and names the rules behind it, although it covers only what was translated into logic.\n\nTo see this in practice, let's build the check step by step.\n\n## Turning a policy into logic\n\nLet's use a small travel policy as the running example. It has four sentences:\n\nTrips exceeding EUR 1,000 require line-manager approval. All international travel requires line-manager approval. Employees may travel internationally once they have completed six months of service. No booking may be made before approval is recorded.\n\nThe examples use SMT (satisfiability modulo theories), which extends Boolean logic with theories such as integer arithmetic, so that rules can talk about amounts and durations. Its standard language, [SMT-LIB](https://smt-lib.org/), borrows its syntax from Lisp: every expression is a list in parentheses with the operator first. So `(> costEur 1000)` means `costEur` is greater than 1000, `(and a b)` means a and b, and `(=> a b)` means a implies b: whenever a is true, b must be true too.\n\nOther approaches can express the same rules. [Answer set programming](https://en.wikipedia.org/wiki/Answer_set_programming) with the [clingo](https://potassco.org/clingo/) solver writes them in a Prolog-like syntax and suits policies built on defaults and exceptions, as well as search problems such as planning or product configuration, where the solver finds combinations that satisfy every rule. SMT suits an output guardrail, a check that runs on an answer before anyone sees it. The guardrail needs to know whether a claim follows from the rules, contradicts them or is left open, and an SMT solver answers that in two runs.\n\nThe solver we use is [Z3](https://github.com/Z3Prover/z3). Microsoft Research [presented it](https://link.springer.com/chapter/10.1007/978-3-540-78800-3_24) at a verification conference in 2008. It has been open source under the MIT licence since 2015. We install it with [uv](https://docs.astral.sh/uv/). The command `uv tool install z3-solver` puts the `z3` command on your path, and the Python scripts later on declare Z3 as a dependency so that uv fetches it on first use. Here is a first run, with the cost sentence and one question:\n\n``` js\n$ z3 -in <<'EOF'\n(declare-const costEur Int)\n(declare-const managerApproved Bool)\n(declare-const tripApproved Bool)\n(assert (=> (and (> costEur 1000) (not managerApproved)) (not tripApproved)))\n(assert (= costEur 2500))\n(assert (not managerApproved))\n(assert tripApproved)\n(check-sat)\nEOF\nunsat\n```\n\n`declare-const` creates a variable with a type, `assert` states something that has to be true, and `check-sat` asks whether the assertions can all hold together. The output, `unsat` (short for unsatisfiable), means they cannot: a EUR 2,500 trip without approval cannot be approved.\n\nA Python `if` statement could encode this rule, and for one rule and one question it would be enough. Python is imperative: it runs your steps, in the order you wrote them, on the values you pass in. SMT-LIB is declarative: you state what must be true, and the solver works out whether any combination of values satisfies all of it, covering every case the rules allow rather than only the ones you happened to test. The difference grows with the policy. With dozens of interacting rules, nested `if` statements soon become hard to write correctly and harder to maintain, while a declarative policy stays a flat list of rules, each one added or changed on its own.\n\nThe whole policy fits in one file, `policy.smt2`:\n\n```\n(set-option :produce-unsat-cores true)   ; report which named rules conflict\n\n; The facts a question can supply, and the outcomes the policy decides.\n(declare-const costEur Int)\n(declare-const international Bool)\n(declare-const managerApproved Bool)\n(declare-const tenureMonths Int)\n(declare-const tripApproved Bool)\n(declare-const bookingAllowed Bool)\n\n; The situations the policy forbids, each defined once so the rules can reuse it.\n(define-fun expensiveUnapproved () Bool (and (> costEur 1000) (not managerApproved)))\n(define-fun abroadUnapproved () Bool (and international (not managerApproved)))\n(define-fun abroadTooSoon () Bool (and international (< tenureMonths 6)))\n\n; The rules. (! ... :named x) gives each one a name the solver can report.\n(assert (! (=> expensiveUnapproved (not tripApproved)) :named cost-rule))\n(assert (! (=> abroadUnapproved (not tripApproved)) :named international-rule))\n(assert (! (=> abroadTooSoon (not tripApproved)) :named six-months-rule))\n(assert (! (= bookingAllowed tripApproved) :named booking-rule))\n\n; Not in the document: a trip that no rule forbids is approved.\n(assert (! (=> (not (or expensiveUnapproved abroadUnapproved abroadTooSoon)) tripApproved)\n           :named closed-world))\n```\n\n`define-fun` names a condition once so the rules can reuse it. Each rule stays a separate named assertion, so that when a trip is refused the solver can report which rule was responsible. The last rule is not in the document. The policy only lists the cases in which a trip is refused, so without this rule nothing could ever be approved. Approving every trip that no rule forbids is an instance of the [closed-world assumption](https://en.wikipedia.org/wiki/Closed-world_assumption), and the policy owner has to confirm it.\n\nCheck the translation before any answer depends on it. The owner of this policy says that exactly six months of service qualifies. A translation that writes `tenureMonths > 6` (strictly more than six, so six itself fails) gives the wrong rule. The solver still reasons correctly from it, so every verdict it returns is a correct proof about the wrong rule. Asking the solver where two translations disagree finds the problem:\n\n``` js\n$ z3 -in <<'EOF'\n(declare-const tenureMonths Int)\n(assert (>= tenureMonths 0))\n(assert (distinct (>= tenureMonths 6) (> tenureMonths 6)))\n(check-sat)\n(get-value (tenureMonths))\nEOF\nsat\n((tenureMonths 6))\n```\n\nSix is the only value where the two readings differ (`distinct`), and it is the boundary the sentence leaves ambiguous. When several translations exist, agreement works as a vote, and each disagreement becomes a question for the policy owner. Test cases find the same error independently: check the owner's answers for specific values (six months qualifies, five does not, twelve does) against each translation, and the `> 6` version fails only the first. The solver has no way to tell whether the rules match the document, so that judgement stays with a person. An LLM-as-a-judge can help here, by flagging the rules that look different from their sentence, but the sign-off is the owner's.\n\n## Checking an answer\n\nAn employee in Hamburg asks whether they can book a EUR 2,500 trip to Berlin that has not been approved yet. We ask the LLM for the answer to the employee and, in the same response, for that answer as logic, with the facts and the conclusion kept apart:\n\n```\nAnswer the employee in plain English. Then, between <smt> and </smt>,\nrestate your answer in SMT-LIB using only these variables:\n\n  costEur         Int   total trip cost in euros\n  international   Bool  the trip crosses a national border\n  managerApproved Bool  a line manager has approved the trip\n  tenureMonths    Int   months of continuous service\n  tripApproved    Bool  the trip is approved under this policy\n\nWrite each fact the question gives you as a named assertion, such as\n(assert (! (= costEur 2500) :named fact-cost)), and write what your answer\nconcludes as (define-fun claim () Bool ...). Assert nothing you were not told.\n```\n\nSuppose the model gets it wrong and says yes:\n\n```\nYes, you can go ahead and book the EUR 2,500 Berlin trip.\n\n<smt>\n(assert (! (= costEur 2500) :named fact-cost))\n(assert (! (not international) :named fact-domestic))\n(assert (! (not managerApproved) :named fact-approval))\n(define-fun claim () Bool tripApproved)\n</smt>\n```\n\nOne short Python script, `verdict.py`, turns that block into a verdict. It uses the Z3 Python library to ask whether the claim can be true and whether it can be false, and the two answers give the verdict. The `# /// script` block is [inline script metadata](https://docs.astral.sh/uv/guides/scripts/), which uv reads to install Z3 before the first run. The flags on the first line keep uv quiet and make it use a prebuilt Z3 package instead of compiling one. After `chmod +x verdict.py`, it runs like any other command:\n\n``` bash\n#!/usr/bin/env -S uv run --quiet --no-build --script\n# /// script\n# dependencies = [\"z3-solver\"]\n# ///\n# Usage: ./verdict.py policy.smt2 answer.smt2\nimport re, sys\nfrom pathlib import Path\nfrom z3 import Solver, sat\n\ndef check(policy, answer, claim):\n    \"\"\"Can the claim hold? Returns an example case, or the rules and facts against it.\"\"\"\n    s = Solver()\n    s.from_string(f\"{policy}\\n{answer}\\n(assert (! {claim} :named checked))\")\n    if s.check() != sat:\n        return False, sorted(str(c) for c in s.unsat_core() if str(c) != \"checked\")\n    model = s.model()\n    values = {str(d): model[d] for d in model}\n    names = re.findall(r\"\\(declare-const (\\w+)\", policy)   # the policy's variables\n    return True, {n: values[n] for n in names if n in values}\n\ndef verdict(policy, answer):\n    can_hold, if_true = check(policy, answer, \"claim\")\n    can_fail, if_false = check(policy, answer, \"(not claim)\")\n    if can_hold and can_fail:\n        return \"SATISFIABLE\", f\"the policy does not decide it\\n  holds when: {if_true}\\n  fails when: {if_false}\"\n    if can_hold:\n        return \"VALID\", f\"the policy proves it\\n  because of: {if_false}\"\n    if can_fail:\n        return \"INVALID\", f\"the policy rules it out\\n  because of: {if_true}\"\n    return \"INCONSISTENT\", f\"the facts contradict the policy\\n  because of: {if_true}\"\n\nif __name__ == \"__main__\":\n    name, text = verdict(Path(sys.argv[1]).read_text(), Path(sys.argv[2]).read_text())\n    print(f\"{name}: {text}\")\n```\n\nSave the part of the model's reply between the tags as `answer.smt2` and run the check:\n\n```\n$ ./verdict.py policy.smt2 answer.smt2\nINVALID: the policy rules it out\n  because of: ['cost-rule', 'fact-approval', 'fact-cost']\n```\n\nThe answer is invalid because of the cost rule and the two facts it applies to. Z3 calls this list the unsat core, a set of assertions that cannot all hold together with the claim, and it does not guarantee the smallest such set by default. You can show it to the employee or hand it back to the model to correct against.\n\nHad a manager approved the trip, the model's block would differ in one line, and the same command proves the answer:\n\n``` bash\n$ cat answer-approved.smt2\n(assert (! (= costEur 2500) :named fact-cost))\n(assert (! (not international) :named fact-domestic))\n(assert (! managerApproved :named fact-approval))\n(define-fun claim () Bool tripApproved)\n$ ./verdict.py policy.smt2 answer-approved.smt2\nVALID: the policy proves it\n  because of: ['closed-world', 'fact-approval', 'fact-domestic']\n```\n\nThis time the policy proves the answer: the closed-world rule and the two facts leave no way to refuse the trip. Now take a EUR 2,000 international trip with approval, from someone whose length of service nobody mentioned:\n\n``` bash\n$ cat answer-abroad.smt2\n(assert (! (= costEur 2000) :named fact-cost))\n(assert (! international :named fact-international))\n(assert (! managerApproved :named fact-approval))\n(define-fun claim () Bool tripApproved)\n$ ./verdict.py policy.smt2 answer-abroad.smt2\nSATISFIABLE: the policy does not decide it\n  holds when: {'costEur': 2000, 'international': True, 'managerApproved': True, 'tenureMonths': 6, 'tripApproved': True, 'bookingAllowed': True}\n  fails when: {'costEur': 2000, 'international': True, 'managerApproved': True, 'tenureMonths': 0, 'tripApproved': False, 'bookingAllowed': False}\n```\n\nThe two example cases differ in one fact the question left out: at six months of service the trip is approved; at zero it is refused. Before answering, the assistant has to ask the employee for `tenureMonths`.\n\nThis example asks for the answer and its translation in a single call, which keeps it short. It also means that the model that wrote the answer translates it, and a model that misread the question will restate the facts it believed, so its logic fits the rules just as its answer did. In production, the translation is a separate step, as it is in the Amazon Bedrock Automated Reasoning checks mentioned earlier. A different model, or the same model with a different prompt, reads only the text and never sees the answering model's reasoning, which makes it much less likely that both get the same thing wrong. The facts come only from the user's question and the claim only from the answer, so the answer cannot add a fact nobody gave. The translator is told to assert only what the text states and to list anything it would have to assume, and each assumption becomes a missing fact that the check asks about.\n\nNot every translation error does harm. A fact left out makes the claim satisfiable, and the check asks for it. The dangerous error is a fact nobody stated, such as an approval nobody gave, because it can make a wrong answer look proven. Evals measure how often that happens, with a set of questions whose facts are known, including some written to mislead: rewordings, negations, or an employee saying their manager has already agreed. The Nearform [guide to building evals for reliable agents](https://nearform.com/digital-community/from-ai-prototype-to-production-how-to-build-evals-for-reliable-agents/) covers that work. The translation is measured rather than proven, and the verdict that follows from it is proven.\n\n## Checking an agent's actions\n\nAn output guardrail checks the facts as the model restated them, and nothing stops a model from asserting `managerApproved` without evidence. When an answer leads to an agent action, check the arguments of the tool call instead, because those are what will run. The policy then works as a precondition, a condition that has to hold before the call is allowed. The booking rule is already in the policy file.\n\nAgent harnesses can run a command before each tool call. In Claude Code, this is a `PreToolUse` [hook](https://code.claude.com/docs/en/hooks): it receives the call as JSON on standard input, and if it exits with code 2, the call is blocked and whatever it wrote to standard error becomes the blocking message. This hook, `booking-hook.py`, reads the call, turns its arguments into facts and passes them to the `verdict()` function from `verdict.py`, asking for `bookingAllowed`:\n\n``` bash\n#!/usr/bin/env -S uv run --quiet --no-build --script\n# /// script\n# dependencies = [\"z3-solver\"]\n# ///\n# Runs before every book_trip call and allows it only if the policy proves bookingAllowed.\n# In production, read manager_approved from the approvals system, not from the call.\nimport json, sys\nfrom pathlib import Path\nfrom verdict import verdict   # verdict.py, in the same folder\n\ncall = json.load(sys.stdin)[\"tool_input\"]\nfacts = f\"\"\"\n(assert (! (= costEur {call['cost_eur']}) :named arg-cost))\n(assert (! (= international {json.dumps(call['international'])}) :named arg-international))\n(assert (! (= managerApproved {json.dumps(call['manager_approved'])}) :named arg-approval))\n(define-fun claim () Bool bookingAllowed)\"\"\"\nname, text = verdict((Path(__file__).parent / \"policy.smt2\").read_text(), facts)\nif name != \"VALID\":\n    print(f\"{name}: {text}\", file=sys.stderr)   # becomes the blocking message\n    sys.exit(2)                    # exit code 2 blocks the tool call\n```\n\nIt is registered in the project's `.claude/settings.json` and, like `verdict.py`, needs `chmod +x`. Here `book_trip` is served by a [Model Context Protocol](https://modelcontextprotocol.io/docs/2026-07-28/getting-started/intro) (MCP) server called `travel`, which is why Claude Code names the tool `mcp__travel__book_trip`:\n\n```\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      {\n        \"matcher\": \"mcp__travel__book_trip\",\n        \"hooks\": [\n          { \"type\": \"command\", \"command\": \"${CLAUDE_PROJECT_DIR}/booking-hook.py || exit 2\" }\n        ]\n      }\n    ]\n  }\n}\n```\n\nAny exit code other than 2 lets the call through, so the `|| exit 2` after the command makes a crash, or a missing uv, block the booking too.\n\nAn unapproved EUR 2,500 booking never reaches the travel system:\n\n``` bash\n$ echo '{\"tool_input\": {\"cost_eur\": 2500, \"international\": false, \"manager_approved\": false}}' | ./booking-hook.py\nINVALID: the policy rules it out\n  because of: ['arg-approval', 'arg-cost', 'booking-rule', 'cost-rule']\n$ echo $?\n2\n```\n\nA precondition is only as sound as the code that binds its values, which is why the comment in the hook says to read `manager_approved` from the approvals system. The environment the call runs in needs controlling too. A container limits what an allowed call can reach but cannot stop one that breaks the policy, and the Nearform write-up on [securing agentic workflows with containers](https://nearform.com/digital-community/securing-agentic-workflows-the-container-approach) covers that side.\n\n## Where it fits\n\nThe approach fits wherever an answer has to follow a written rule that can be stated in concrete variables. Lending is the clearest case. Age, income, deposit and term are numbers, lenders already document their rules, and the European Union's AI Act classifies AI systems that evaluate the creditworthiness of individuals as [high-risk](https://artificialintelligenceact.eu/annex/3/). Clinical trial protocols are another, since eligibility criteria, dosage limits and required steps are set out before the trial starts. The everyday case is an internal assistant for human resources (HR) policy, answering questions about leave, expenses or benefits, with each answer checked against the policy before the employee sees it.\n\n## Where it stops\n\nFive limits are worth knowing before you start.\n\n- **Logic has a narrow scope.** It covers amounts, dates, categories and workflow steps, not tone or subjective judgement. A policy written around words like \"reasonable\" will either resist translation or be translated into something more precise than the document says.\n- **The solver can fail to decide.** With rules that multiply two variables, Z3 can answer`unknown` instead of`sat` or`unsat` .`verdict.py` counts that as impossible, so a production check should report it separately, as a failed check rather than a verdict.\n- **A satisfiable verdict is not permission.** It is weaker than a proof, and treating it as approval discards the only guarantee the check offers.\n- **A policy made only of prohibitions can never prove a yes.** Remove the closed-world rule from the policy file and the approved trip above comes back satisfiable instead of valid.\n- **Every check has a cost.** The model writes a few extra lines of logic with each answer, and the solver runs twice. Translating and reviewing the policy is a one-off cost for each version of the document. The extra step is easy to justify on a lending decision, less so on a chatbot reply that carries no compliance risk.\n\n## Back to the initial error\n\nThe rule MyCity got wrong is a specific section of the city's Administrative Code, and it translates into a single assertion. The file below is simplified: the law also defines which businesses count, and a real translation would include those details and be reviewed by the city's lawyers.\n\n``` js\n(set-option :produce-unsat-cores true)\n\n(declare-const foodOrRetail Bool)     ; the business is a food store or retail establishment\n(declare-const paidOnPremises Bool)   ; the customer pays on the premises\n(declare-const mustAcceptCash Bool)   ; the law requires the business to accept cash\n\n; NYC Administrative Code § 20-840, simplified: food stores and retail\n; establishments must accept cash for payments made on the premises.\n(assert (! (= mustAcceptCash (and foodOrRetail paidOnPremises)) :named cash-rule))\n```\n\nThe chatbot's answer, translated the same way as the travel answers, says the business is a restaurant, its customers pay on the premises, and it does not have to accept cash:\n\n``` bash\n$ cat mycity.smt2\n(assert (! foodOrRetail :named fact-restaurant))\n(assert (! paidOnPremises :named fact-pays-at-table))\n(define-fun claim () Bool (not mustAcceptCash))\n$ ./verdict.py cash-law.smt2 mycity.smt2\nINVALID: the policy rules it out\n  because of: ['cash-rule', 'fact-pays-at-table', 'fact-restaurant']\n```\n\nThe cash rule and the two facts that trigger it rule the chatbot's answer out, and that list is what a restaurant owner needed to see. The chatbot's other wrong answers reported by The Markup, about taking workers' tips and refusing housing vouchers, were about written rules too, and each could be checked like this one.\n\n## What a proof adds\n\nTeams already have ways to improve LLM answers. Evals measure how often an agent is right across a dataset, and the Nearform guide to [building evals for reliable agents](https://nearform.com/digital-community/from-ai-prototype-to-production-how-to-build-evals-for-reliable-agents/) covers that work. Prompt techniques make a model more likely to follow a rule, as in the Nearform post on r[e-anchoring agent safety with a Mastra processor](https://nearform.com/digital-community/the-one-line-fix-re-anchoring-agent-safety-with-a-mastra-processor). An LLM-as-a-judge can quote the rule it thinks was broken and explain its reasoning. A policy expressed as logic adds a verdict to each individual answer, with a proof whenever the answer is valid or invalid. For an invalid answer, it shows that no case allowed by the rules makes the answer true, then identifies the rule and values that exclude it. A compliance reviewer gets a record to check without trusting the model.\n\nTo try it on one of your own policies:\n\n- pick a document with concrete variables and a named owner, such as an expenses policy or a set of mortgage eligibility criteria;\n- have an LLM translate a few of its sentences into SMT-LIB, and ask the owner to compare each rule with its source sentence;\n- write test cases from the document's own boundaries before the rules go into use;\n- put a verdict script like the one above in front of one LLM answer or one agent action, and send every satisfiable verdict back to the user as a question.\n\nIf you're planning to take a check like this into production, [Nearform](https://nearform.com/contact/) can help you set up the project and the review process from the start, drawing on our experience in AI-native engineering, so that the answers your agents give are ones you can prove.\n\n## But wait - there's more.\n\nNearform publishes real-world learnings on data & AI, engineering, and digital strategy - with more merged in weekly.\n\n### Insights\n\nPerspectives on AI in engineering, product development, and strategy, for enterprise executives.\n\n### Community\n\nDeep dives and tutorials by engineers, for engineers.\n\n## You may also like\n\n### The one-line fix: re-anchoring agent safety with a Mastra processor\n\n[Pasquale Carmine Carbone](https://nearform.com/authors/pasquale-carmine-carbone)· 22 Jul 2026 · 9 min read", "url": "https://wpnews.pro/news/neurosymbolic-guardrails-for-ai-agents-answers-you-can-prove", "canonical_source": "https://nearform.com/digital-community/your-ai-agent-says-yes-can-it-prove-it", "published_at": "2026-10-29 00:00:00+00:00", "updated_at": "2026-10-29 11:18:14.070120+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "artificial-intelligence", "large-language-models"], "entities": ["Nearform", "MyCity", "The Markup", "Air Canada", "NASA", "Remote Agent", "Deep Space 1", "Intel"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/neurosymbolic-guardrails-for-ai-agents-answers-you-can-prove", "markdown": "https://wpnews.pro/news/neurosymbolic-guardrails-for-ai-agents-answers-you-can-prove.md", "text": "https://wpnews.pro/news/neurosymbolic-guardrails-for-ai-agents-answers-you-can-prove.txt", "jsonld": "https://wpnews.pro/news/neurosymbolic-guardrails-for-ai-agents-answers-you-can-prove.jsonld"}}