Powered by AppSignal & Oban Pro

Scenario 5 — The model, by hand

05_agent_policy_proof.livemd

Scenario 5 — The model, by hand

unless Code.ensure_loaded?(Goatmire.Verifier) do
  repo = System.get_env("LIVEBOOK_GOATMIRE_DIR") || Path.expand("../..", __DIR__)
  unless File.regular?(Path.join(repo, "mix.exs")) do
    raise "Open this notebook from a full Goatmire clone, or set LIVEBOOK_GOATMIRE_DIR to that clone. See notebooks/README.md."
  end
  maude = System.get_env("MAUDE_PATH") || System.find_executable("maude") ||
    Path.join(repo, "deps/ex_maude/priv/maude/bin/maude")
  unless File.regular?(maude) do
    raise "Install Maude in the clone with mix deps.get and mix maude.install --version 3.5.1, or set MAUDE_PATH."
  end
  Mix.start()
  Mix.env(:prod)
  Mix.install(
    [{:goatmire, path: repo, env: :prod}],
    config: [ex_maude: [maude_path: maude]],
    config_path: :goatmire,
    lockfile: :goatmire,
    start_applications: false
  )
  Application.put_all_env(goatmire: [role: :notebook, transport: Goatmire.Transport.Local,
    metrics_enabled: false, vda5050_enabled: false, autostart_fleet: false,
    real_devices: [], modbus_sensors: []])
  {:ok, _} = Application.ensure_all_started(:goatmire)
end

What this notebook is for

Nothing moves here. No fleet, no broker, no network, no language model. One agent policy, one call, one typed answer.

It is the slowest beat in the talk on purpose: with nothing else on screen, the audience can read the term and the typed conflict that rejects it. It is also the fallback — if everything else on stage falls over, this still runs.

Goatmire.Verifier.health()

A policy with a hole in it

An agent that performs a high-impact action, with no approval gate in front of it.

unsafe = [
  %{
    id: "autodose-controller",
    agent_id: {"acme", "controller"},
    trigger: {:always},
    invocations: [{:invoke_tool, "dose", %{}, "high_impact", :eu}]
  }
]

Four fields — plain data, like an Elixir struct. Three more (capability_grants, authority_required, priority) are optional and defaulted. {:invoke_tool, name, args, capability, jurisdiction} is a tuple the detector pattern-matches on — nothing is parsed out of prose.

{:ok, conflicts} = ExMaude.AI.detect_conflicts(unsafe, jurisdictions: [:eu])
Enum.map(conflicts, &{&1[:type], &1[:rule1], &1[:reason]})

:approval_gate_bypass. Not a score, not a judge, not a confidence interval — a typed conflict with a reason string, produced by equational reduction.

Add the gate

gated = [
  %{
    id: "autodose-controller",
    agent_id: {"acme", "controller"},
    trigger: {:always},
    invocations: [
      {:require_approval, "dosing_high_delta"},
      {:invoke_tool, "dose", %{}, "high_impact", :eu}
    ]
  }
]

{:ok, none} = ExMaude.AI.detect_conflicts(gated, jurisdictions: [:eu])
none

Empty. Precisely: this detector found none of its seven modelled conflict types. That is deterministic evidence, not an LLM judgment, and not a claim of complete policy safety.

A different failure — sovereignty

outside = [
  %{
    id: "research-assistant",
    agent_id: {"acme", "researcher"},
    trigger: {:always},
    invocations: [{:invoke_tool, "search", %{}, "internet_access", :us}]
  }
]

{:ok, sovereignty} = ExMaude.AI.detect_conflicts(outside, jurisdictions: [:eu, :ch])
Enum.map(sovereignty, & &1[:type])

"This data may not leave the EU" is not a policy document here. It is a closed sort enumeration, and an invocation routed outside the allowed set is a term the equations reject.

The drift check

The stage run asserts its own expected outcomes, so a library change that quietly alters a verdict is caught in rehearsal rather than in front of an audience.

Goatmire.VerificationDemo.run()

If that returns {:error, {:unexpected_verification_results, _}}, do not present this beat until you understand why. A demo whose expectations have silently moved is a demo making a claim nobody has checked.

The raw command

This is the command ExMaude sends — assembled from the real encoder's output, not hand-maintained here.

alias ExMaude.AI.Encoder

{:ok, encoded} = Encoder.encode_rules(gated)
jurisdictions = Encoder.encode_jurisdiction_set([:eu])

"reduce in AI-CONFLICT-DETECTOR : " <>
  "detectAllConflicts(#{encoded}, #{jurisdictions}) ."

Same machinery you watched inspect robot rules, now inspecting an AI agent policy. Different template, identical mechanism: sorts, operators, equations.