Verdict
Verdict is a DMN 1.5 decision engine for Go. It loads a decision model, tells you what is wrong with it before you deploy it, evaluates it, and hands back a trace of exactly what it did and why.
It also does one thing DMN does not: a decision can be delegated to an agent — an LLM behind a typed, bounded, traced boundary — without the rest of the graph losing any of its determinism.
model.dmn ──▶ load ──▶ analyse ──▶ evaluate ──▶ outputs
│ │ │
▼ ▼ ▼
diagnostics gaps & trace
overlaps
What is here
| If you want to… | Read |
|---|---|
| Install it and run a model | Installation and a First Model |
| Drive it from a shell | The Command Line |
| Write a decision table that holds up | Decision Tables |
| Put a model inside a decision safely | Agent Decisions |
| Audit what an evaluation did | Traces |
| Open a Verdict model in Camunda Modeler | DMN XML and Interoperability |
| Author or generate models as JSON | Verdict Decision JSON |
| Validate a model file in CI or an editor | The VDJ JSON Schema |
| Run it as a service | Deployment |
| Let an agent drive it | MCP Tools and Resources |
| Wire it into Nexus | Nexus Integration |
| Look up an error code | Diagnostic Codes |
Three commitments
DMN is the spec, not the inspiration. The graph model, the boxed expressions, the hit policies and FEEL come from DMN 1.5. Verdict does not invent a name for something DMN already names, and does not quietly improve a behaviour the spec fixes. Where it deviates, the deviation is documented.
The trace is output, not logging. Every evaluation produces a record of which nodes fired, which rules matched, and what an agent was asked. Its JSON shape is a stable contract that dashboards and auditors read — not a debug aid that might change shape next release.
Interoperability is verified, not asserted. Every shipped model is validated
against the OMG DMN 1.3 XSD by make validate, and every VDJ document Verdict
writes is validated against the published JSON Schema by
the test suite. “It parses in our own reader” is not a claim about anyone
else’s tool.
The machine-readable contract
The VDJ JSON Schema is published at its own $id:
https://frankbardon.github.io/verdict/vdj-schema.json
It is generated from the same Go types the reader decodes into and the same
vocabulary registry the engine evaluates against, so it cannot drift from the
engine that serves it. verdict schema prints the identical bytes offline.
Getting started
Install
go get github.com/frankbardon/verdict
go install github.com/frankbardon/verdict/cmd/verdict@latest
A model in ten lines
Verdict reads DMN XML and Verdict Decision JSON. JSON is the shorter way to see the shape:
{
"vdj": "1.0",
"id": "shipping",
"input_data": [
{ "id": "order", "name": "Order", "variable": { "name": "Order" } }
],
"decisions": [
{
"id": "shipping_band", "name": "Shipping Band",
"required_inputs": ["order"],
"variable": { "name": "Shipping Band", "type_ref": "string" },
"logic": {
"kind": "decisionTable",
"hit_policy": "UNIQUE",
"inputs": [{ "label": "Order value", "expression": "Order.value", "type_ref": "number" }],
"outputs": [{ "name": "band", "type_ref": "string",
"default_value": "\"standard\"" }],
"rules": [
{ "id": "r1", "when": ["< 25"], "then": ["\"economy\""] },
{ "id": "r2", "when": ["[25..100)"], "then": ["\"standard\""] },
{ "id": "r3", "when": [">= 100"], "then": ["\"free\""] }
]
}
}
]
}
$ verdict eval shipping.vdj -d '{"Order":{"value":150}}'
{
"duration_ms": 0,
"outputs": { "Shipping Band": "free" }
}
From Go
package main
import (
"context"
"fmt"
"github.com/frankbardon/verdict/pkg/verdict"
)
func main() {
engine, err := verdict.NewEngine()
if err != nil {
panic(err)
}
model, err := engine.LoadModel(verdict.FromFile("shipping.vdj"))
if err != nil {
panic(err)
}
res, err := engine.Evaluate(context.Background(), model.ID, verdict.Inputs{
"Order": map[string]any{"value": 150},
})
if err != nil {
panic(err)
}
fmt.Println(res.Outputs["Shipping Band"]) // free
}
A *verdict.Engine is safe to share across goroutines and holds a registry of
loaded models, so a service loads its models once at startup.
Check the model before you trust it
$ verdict analyze shipping.vdj
Model shipping
DECISION POLICY RULES GAPS OVERLAPS UNREACHABLE NOTE
Shipping Band U 3 0 0 0
0 error(s), 0 warning(s)
verdict analyze exits non-zero on an error-severity finding, so it belongs in
CI next to your linter. verdict.WithStrictMode(true) makes the engine refuse
such a model at load instead.
Read what a decision does
verdict explain shipping.vdj # index the model
verdict explain shipping.vdj shipping_band # one decision, in full
Read what happened
verdict eval shipping.vdj -d '{"Order":{"value":150}}' --trace --quiet \
| verdict trace --values
shipping — shipping
shipping [evaluation] 95µs
└── Shipping Band [decisionTable] 61µs
hit_policy: "U"
matched_rules: ["r3"]
inputs: {"Order":{"value":150}}
output: "free"
Open it in a modeller
verdict convert shipping.vdj --to xml --out shipping.dmn
The output is DMN 1.3 — the version Camunda Modeler and dmn-js read — with
auto-laid-out diagram interchange, so it opens as a diagram rather than an empty
canvas. Edit it there, and verdict eval shipping.dmn picks up the changes.
Validate a model before you load it
If you are generating models rather than drawing them, point a validator at the published JSON Schema:
verdict schema > vdj-schema.json
check-jsonschema --schemafile vdj-schema.json shipping.vdj
Or put its URL in the file and let your editor do it:
{ "$schema": "https://frankbardon.github.io/verdict/vdj-schema.json", "vdj": "1.0", "…": "…" }
Next
- The command line — every subcommand and flag
- Decision tables — hit policies, gaps and overlaps
- Agent decisions — putting an LLM inside a decision graph
- Traces — the execution record and what reads it
- Verdict Decision JSON and its JSON Schema
- DMN XML and interoperability — opening a model in a modeller
- Nexus integration — both directions
- Deployment —
verdict serve, Twirp and MCP
The Command Line
verdict is a thin adapter over the library: every subcommand parses flags,
calls the library and formats the result. No decision logic lives in it, which
is why the CLI, the server and an embedded engine cannot disagree about what a
model means.
verdict eval evaluate a model against inputs
verdict analyze report gaps and overlaps
verdict explain describe what a decision depends on and how it decides
verdict convert move a model between DMN XML and VDJ
verdict trace render a saved trace as a readable tree
verdict schema print the VDJ JSON Schema
verdict serve serve models over Twirp and MCP
verdict mcp serve MCP over stdio
There is one binary. The server is a subcommand, not a second artefact, so the flags, the configuration file and the agent-bridge construction are shared with the commands you run locally — there is nothing that can drift between what CI checks and what production runs.
Every subcommand accepts the global --config (or $VERDICT_CONFIG) pointing
at a YAML configuration file. Every subcommand reads either format: .dmn and
.vdj are interchangeable inputs everywhere.
eval
verdict eval [options] <model.dmn|model.vdj>
Evaluates the model’s top-level decisions and writes the outputs as JSON on stdout. Diagnostics go to stderr, so stdout stays machine-readable.
$ verdict eval examples/pricing/pricing.dmn -d '{
"Customer": {"segment":"enterprise","seats":250,"tenure_years":3,"region":"emea"},
"Order": {"plan":"business","term_months":24,"promo_code":""}
}'
{
"duration_ms": 0,
"outputs": {
"Quote": {
"discount percent": 20,
"list price per seat": 39,
"monthly total": 7800,
"plan": "business",
"price per seat": 31.2,
"seats": 250,
"term total": 187200,
"volume tier": "large"
}
}
}
| Flag | |
|---|---|
--decision <id|name> | Evaluate one decision instead of the model’s outputs |
--service <id|name> | Evaluate a decision service |
--input, -i <file> | Input values from a JSON file |
--data, -d <json> | Input values as a literal JSON object |
--trace | Include the execution trace in the output |
--quiet | Suppress diagnostics on stderr |
--bridge <mock|http|none> | Agent bridge, overriding the configuration |
--agent-endpoint <url> | Endpoint for the http bridge |
--agent-answer <DECISION=VALUE> | Canned answer for the mock bridge; repeatable |
Exit status is 1 when the evaluation fails and 2 when it succeeds but
the model reported an error-severity diagnostic — an evaluation that returned
an answer it is not confident in is not a success you should pipe onward
unexamined.
The mock bridge is what makes agent decisions testable without a model provider:
verdict eval examples/loan_approval/loan_approval.dmn \
--bridge mock --agent-answer risk_tier_agent=low \
-i applicant.json
analyze
verdict analyze [options] <model>
Probes every decision table’s input space and reports the combinations no rule covers (gaps), the combinations several rules cover (overlaps), and the rules an earlier rule makes unreachable.
| Flag | |
|---|---|
--json | Emit the full report as JSON |
--strict | Treat gaps as errors |
Exit status 2 on any error-severity finding, so this is directly usable as a CI gate:
verdict analyze model.dmn --strict || exit 1
The analysis runs the real unary tests through the real evaluator — it does not reinterpret the table’s source text — so the report cannot drift from runtime behaviour. See Decision Tables for what a gap means and when one is deliberate.
explain
verdict explain [options] <model> [decision]
Prints the DRG slice for a decision: the inputs a caller must supply, the decisions it builds on, the knowledge it may invoke, and its logic rendered in full — the rules of a decision table, or the bindings, output type and failure policy of an agent decision.
With no decision named, lists the model’s decisions and services. That is the right first command against a model you did not write.
| Flag | |
|---|---|
--json | Emit the slice as JSON |
convert
verdict convert [options] <model>
Reads either format and writes the other, or the one named by --to. The
projection is lossless in both directions.
| Flag | |
|---|---|
--to <xml|json> | Output format (default: the opposite of the input) |
--dmn-version <1.3|1.4|1.5> | DMN namespace to emit (default 1.3) |
--no-diagram | Omit diagram interchange |
--out, -o <file> | Write to a file instead of stdout |
DMN XML is written at 1.3 with generated diagram interchange, because that is what editors read and draw. See DMN XML and Interoperability.
trace
verdict trace [options] [trace.json]
Reads a trace produced by verdict eval --trace (or by the server’s GetTrace
endpoint) and prints it as an indented tree: which decisions fired, what each
was given, what it produced, which rules matched, and what an agent decision was
asked and answered. Reads stdin when given no file.
| Flag | |
|---|---|
--values | Show each node’s inputs and output |
verdict eval model.dmn -i inputs.json --trace | jq .trace | verdict trace --values
See Traces.
schema
verdict schema [--out vdj-schema.json]
verdict schema --validate model.vdj
Prints the VDJ JSON Schema (draft 2020-12) to stdout.
No model, no network. The bytes are identical to the copy published at
https://frankbardon.github.io/verdict/vdj-schema.json and to the MCP resource
verdict://schema.
| Flag | |
|---|---|
--out, -o <file> | Write the schema to a file instead of stdout |
--validate <file> | Check a VDJ document against the schema instead of printing it |
--validate needs no other tooling, and is worth running before verdict eval:
$ verdict schema --validate model.vdj
model.vdj does not validate against https://frankbardon.github.io/verdict/vdj-schema.json:
- at '/decisions/0/logic': additional properties 'hit_polciy' not allowed
Exit 2 when the document does not validate, 1 if the file cannot be read.
That example is the case that motivates it: the loader tolerates fields it does
not recognise — a newer VDJ version is a likelier explanation than a typo — so
hit_polciy loads as no hit policy at all and the table silently becomes
UNIQUE. The schema is the only place that is caught.
Or export it and point a validator or an editor at it:
verdict schema > vdj-schema.json
check-jsonschema --schemafile vdj-schema.json model.vdj
serve
verdict serve [options]
Loads the models named on the command line or in the configuration and serves
them over Twirp at --twirp-path, MCP at /mcp, and health at /healthz and
/readyz.
| Flag | |
|---|---|
--model, -m <path> | Model file or directory to load; repeatable. Directories are scanned non-recursively |
--listen, -l <addr> | Address to bind (default :7430, $VERDICT_LISTEN) |
--twirp-path <prefix> | Path prefix for the Twirp endpoint (default /twirp) |
--no-mcp | Disable the MCP endpoint |
--mcp-allow-load | Expose verdict_load_model |
--model-root <dir> | Directory model loading may read paths from; unset forbids loading by path |
--strict | Refuse to load a model with error-severity findings |
--log-level <level> | debug, info, warn or error |
Plus the agent flags shared with eval: --bridge, --agent-endpoint,
--agent-answer.
The process refuses to start with no models, or if any model fails to load. A decision server that is up with an empty registry answers every call with a 404 the caller may not check — worse than one that is down.
verdict serve -m ./models --listen :7430 --strict
See Deployment.
mcp
verdict mcp [options]
Serves the MCP surface over stdin and stdout — the same tools and resources
verdict serve exposes at /mcp, without a listener. This is what an MCP
client that launches Verdict as a subprocess talks to.
{
"command": "verdict",
"args": ["mcp", "--model", "/path/to/models"]
}
It takes the model and agent flags from serve, and none of the HTTP ones:
--listen, --twirp-path and --no-mcp are rejected rather than silently
ignored.
Stdout is the transport. Logs and diagnostics go to stderr; do not pipe this command into anything but an MCP client.
Decision tables
A decision table is DMN’s central construct, and the reason DMN exists: a way of writing rules that a domain expert can read and a machine can execute without a translation step in between.
Anatomy
┌─ input clauses ─────────┐ ┌─ output clause ─┐
RULE HIT: U Credit score Region → credit rating
r1 >= 740 - "excellent"
r2 [670 .. 740) - "good"
r3 < 670 "EU" "fair"
r4 < 670 not("EU") "poor"
- Input clauses carry a FEEL expression evaluated once per table evaluation, not once per rule. That matters when the expression is expensive or invokes a business knowledge model.
- Rules carry one unary test per input clause. A test is not an expression: the value being tested is implicit.
- Output clauses carry a name and, optionally, an
outputValueslist and adefaultOutputEntry.
A table with one output clause produces that clause’s value directly. A table with two or more produces a context keyed by output name. This catches people out; it is DMN 1.5 §8.3.
Unary tests
| Form | Meaning |
|---|---|
- or empty | any value |
42, "eu", true | equals |
< 10, >= 740 | comparison against the input |
[1..10] | closed range |
[1..10) or [1..10[ | closed below, open above |
(1..10] or ]1..10] | open below, closed above |
"a", "b" | any of |
not("a", "b") | none of |
list contains([1,2], ?) | an arbitrary predicate, with ? as the input |
? names the value being tested. It is only needed when the test is not one of
the shorthand forms.
DMN spells an exclusive bound two ways — (1..10] and ]1..10] are the same
interval — and Verdict accepts both, because modellers and exporters both use
both. The one place the bracket spelling costs something is a list index inside
an interval’s upper endpoint: write [1..(xs[1])], since a bare [1..xs[1]]
is genuinely ambiguous with the closing bracket.
Hit policies
The hit policy says what happens when zero, one or several rules match. Choosing the right one is the difference between a table that documents its own intent and a table whose intent lives in a comment.
Single-hit policies
| Policy | Behaviour |
|---|---|
U UNIQUE | Exactly one rule may match. Two matches is a runtime error. The default, and the right default: it makes the table’s totality a checkable claim |
A ANY | Several may match, but they must agree. Disagreement is an error |
P PRIORITY | Several may match; the one whose output ranks highest in outputValues wins |
F FIRST | Several may match; the first in row order wins |
UNIQUE and ANY fail loudly on ambiguity. PRIORITY and FIRST resolve it —
which means they can also hide it, so verdict analyze reports their overlaps
as information rather than staying silent.
Multiple-hit policies
| Policy | Behaviour |
|---|---|
C COLLECT | A list of every matching rule’s output, in rule order |
C+ COLLECT SUM | The sum |
C< COLLECT MIN | The minimum |
C> COLLECT MAX | The maximum |
C# COLLECT COUNT | The number of matches |
R RULE ORDER | Every match, in rule order |
O OUTPUT ORDER | Every match, sorted by outputValues |
A collecting policy that matches nothing yields the empty list, not null —
except the aggregators, which yield null (C# yields 0). If zero is what you
mean, say so with a defaultOutputEntry.
The outputValues trap
PRIORITY and OUTPUT ORDER rank by the output clause’s outputValues list,
most-preferred first. This is the most common DMN modelling bug, because
getting it backwards is silent: the table returns the least preferred answer
and nothing complains.
<!-- A cap: the most generous cap must rank highest, or an education
customer silently gets the commercial cap. -->
<outputValues><text>40,30,20</text></outputValues>
Closing a table
A table is total when every possible input combination matches something. Three ways to get there:
- Write rules that partition the input space (best — the totality is visible).
- Add a
defaultOutputEntryto each output clause. - Under
FIRST, add a catch-all last row with-in every input.
Under UNIQUE, option 3 is not available: a catch-all row overlaps everything.
Gaps and overlaps
verdict analyze probes each table’s input space and reports what it finds.
How it works. Exhaustively checking a table is impossible in general — an
input of type number has an infinite domain. What makes it tractable is that a
rule set built from unary tests partitions its inputs at a finite number of
boundaries. Verdict collects every literal and range endpoint the rules mention,
probes each one plus the values immediately either side, plus one value outside
everything the table mentions, and runs the real rules against each probe.
That last detail matters: the analyser calls the same evaluator the runtime does, so its report cannot drift from what the engine will actually do.
Declare inputValues when you can. A clause that declares its domain —
[0..50], or "domestic","eu","world" — is closed: probes outside it are
dropped rather than reported, because a value the model says cannot occur is not
a hole. Without a declared domain, a number input is infinite and the “one
value outside everything” probe will always be uncovered, so the table can never
be proved complete. Declaring the domain is what makes --strict a gate a
correct model passes.
What it reports:
- gaps — combinations nothing covers. Harmless with a default or a collecting policy; a hole otherwise.
- overlaps — combinations several rules match. An error under
U, and underAwhen the rules disagree; informational elsewhere. - unreachable rules — under
FIRST, a row an earlier row already covers. Not reported forPRIORITY, where row order is not precedence.
DECISION POLICY RULES GAPS OVERLAPS UNREACHABLE
Discount Points C+ 6 1 98 0
Discount Cap P 3 0 2 0
Ninety-eight overlaps in a C+ table is not a problem — stacking is the point.
Two overlaps in a P table is exactly what PRIORITY is for. The number to
look at is the one under a policy that forbids it.
As a CI gate. verdict analyze exits 2 when it reports an
error-severity finding, and --strict raises gaps to errors:
verdict analyze model.dmn --strict # exit 2 if any table has a hole
The report is printed either way — a gate that fails without saying what it found just sends the reader back to run the command again.
Orientation
DMN allows rules as rows or as columns. Verdict evaluates both identically and
preserves the preferredOrientation through a round-trip, so an editor that
renders your table sideways gets it back sideways.
Agent decisions
An agentDecision is Verdict’s one extension to DMN: a node in the decision
graph whose value comes from an agent rather than from a rule.
It exists because the alternative is worse. Systems that need both rules and
language models usually end up with the boundary between them scattered across
application code — a prompt here, a threshold there, a if resp == "yes" in a
handler — where nobody can see it and nothing enforces it. Making the boundary a
node type puts it in the model, where it is reviewable.
The shape
An agent decision lives in its decision’s <extensionElements>:
<decision id="risk_tier" name="Risk Tier">
<extensionElements>
<verdict:agentDecision id="risk_tier_agent" typeRef="tRiskTier">
<verdict:promptTemplate>
Classify this application's risk tier as one of: low, medium, high.
Credit rating: {{ .creditRating }}
Affordability: {{ .affordability }}
Underwriter notes: {{ .notes }}
</verdict:promptTemplate>
<verdict:inputBinding name="creditRating" feel="Credit Rating"/>
<verdict:inputBinding name="affordability" feel="Affordability"/>
<verdict:inputBinding name="notes" feel="Applicant.notes"/>
<verdict:outputType typeRef="string">
<verdict:enumeration>
<verdict:value>low</verdict:value>
<verdict:value>medium</verdict:value>
<verdict:value>high</verdict:value>
</verdict:enumeration>
</verdict:outputType>
<verdict:validator feel='value in ["low", "medium", "high"]'/>
<verdict:policy maxLatency="PT5S" maxRetries="2"
onFailure="fallback" fallbackDecision="risk_tier_heuristic"/>
</verdict:agentDecision>
</extensionElements>
<question>How would an underwriter read this applicant's story?</question>
<variable name="Risk Tier" typeRef="tRiskTier"/>
<!-- information requirements... -->
</decision>
(Indentation compressed for the page; the real element nests one level deeper.)
Why extensionElements and not the decision-logic slot. DMN’s tDecision
ends with <xsd:element ref="expression"/> — the decision-logic slot accepts
only elements in DMN’s own expression substitution group. A foreign element
there makes the whole document fail schema validation, so a modeller opening
it in Camunda Modeler gets an error rather than a diagram. extensionElements
is <xsd:any namespace="##other">: the one place the schema invites a foreign
element, and therefore the only placement that actually degrades gracefully.
Verdict’s reader accepts both placements, because early Verdict models used the other one. Its writer only ever emits this one.
The contract
1. The agent sees only its bindings
Each inputBinding is a FEEL expression evaluated against the decision’s own
context. The agent receives the results — never the context itself.
This is the same encapsulation DMN applies to a business knowledge model’s parameters, and it is a security property, not a style preference. An agent that can see the whole context can see the applicant’s identifier, and a model that can see an identifier can learn to key on it.
Bind the minimum. If a decision needs the applicant’s tenure, bind the tenure, not the applicant.
2. The output type is declared and enforced
The declared type is used twice: it is projected into a provider-side schema before the call, so the model is constrained rather than corrected; and it is checked on the way back, because a schema is a strong hint and not a guarantee.
Coercion is generous about representation and strict about meaning. A model
that answers "42" for a number is accepted, because the text is unambiguous. A
model that answers "high risk" for an enumeration of low/medium/high is
rejected, because guessing would be inventing a decision.
3. The validator is the escape hatch
validator is a FEEL expression evaluated with the coerced answer bound to
value. Use it for anything the type system cannot express:
<verdict:validator feel='value.confidence >= 0.6 and value.tier != null'/>
A non-true result is a failure, and the failure policy takes over.
4. Failure is a typed policy
onFailure | Behaviour |
|---|---|
error (default) | Propagate; the evaluation fails |
null | Bind null and continue |
fallback | Evaluate fallbackDecision and use its result |
Pick the failure mode you can live with. For a moderation model, null plus a
catch-all “send to a human” rule means an outage routes posts to review and
never publishes them. For a loan model, fallback to a deterministic heuristic
means the product keeps working while the model is down.
Write the fallback as a real decision. It should be the heuristic you would
have shipped without an LLM, not a stub. In the loan example, Risk Tier Heuristic is a six-rule PRIORITY table that is perfectly serviceable on its
own — the agent is an improvement on it, not a replacement for it.
5. Latency and retry belong to the engine
maxLatency bounds the whole attempt sequence and maxRetries counts
additional attempts. Both are enforced by the engine through context
cancellation, not by the bridge, so every bridge gets identical guarantees and a
bridge author cannot accidentally opt out of them.
Bridges
type Bridge interface {
Invoke(ctx context.Context, req Request) (Response, error)
}
That is the whole interface. The bridge receives a rendered prompt, the bound inputs, and the declared output type; it returns a value. It does not know it is inside a decision graph, and it does not implement retry, timeouts or validation.
Three ship in the box:
pkg/agent/mock— deterministic answers for tests and CI, with a recorded call log.pkg/agent/http— POSTs a documented JSON envelope to any endpoint.nexus/(separate module) — a Nexus session, with a workspace, tools and an event-bus record. See Nexus integration.
Writing one is a function:
bridge := agent.BridgeFunc(func(ctx context.Context, req agent.Request) (agent.Response, error) {
// req.Prompt, req.Inputs, req.OutputType
return agent.Response{Value: "low", SessionRef: "run-42"}, nil
})
SessionRef is recorded in the trace. Set it to whatever identifies the run in
your world — a request ID, a session, a log URL — so an auditor reading the
trace six months later can find the conversation.
Where to draw the line
| Signal | Rule | Agent decision |
|---|---|---|
| Must be identical on re-run | ✓ | |
| Someone signs off on the logic | ✓ | |
| Input is a number, code, date or enum | ✓ | |
| Input is free text needing interpretation | ✓ | |
| A rule would need dozens of cases for context | ✓ | |
| Being wrong is expensive and unrecoverable | ✓ | only with a fallback |
A healthy hybrid model is mostly tables. The loan example has five decision tables and one agent node; the moderation example has three tables, a literal expression acting as a cost gate, and one agent node that most traffic never reaches. If more than one or two nodes in a graph are agent decisions, the boundary is probably in the wrong place.
Traces
The trace is Verdict’s primary output. Outputs tell you what was decided; the trace tells you how, and it is what makes a decision engine auditable rather than merely fast.
Shape
type Trace struct {
ModelID string
ModelHash string // content address of the model that ran
Entry string // what was asked for
Root *Node
StartedAt time.Time
Duration time.Duration
}
type Node struct {
DecisionID string
DecisionName string
NodeKind string // "decisionTable", "agentDecision", "invocation", …
Inputs map[string]any
Output any
Children []*Node
Annotations map[string]any
StartedAt time.Time
Duration time.Duration
Error string
}
The JSON encoding is a stable contract. Dashboards, the Nexus event bus and the
verdict trace renderer all read it.
Annotations
Annotation keys are exported constants in pkg/trace and are part of the
contract — keys get added, never renamed.
| Key | On | Meaning |
|---|---|---|
hit_policy | decision tables | The policy in shorthand (U, C+, …) |
aggregation | collecting tables | SUM, MIN, MAX, COUNT |
matched_rules | decision tables | The rule IDs that fired |
rule_count | decision tables | How many rules were considered |
input_values | decision tables | Each input clause’s evaluated value |
defaulted | decision tables | No rule matched; the default was used |
invoked_bkm | invocations | The business knowledge model called |
agent_prompt | agent decisions | The rendered prompt |
agent_session_ref | agent decisions | The bridge’s identifier for the run |
agent_attempts | agent decisions | How many attempts it took |
agent_tokens | agent decisions | Cost telemetry, when the bridge reports it |
fallback_used / fallback_from | agent decisions | The agent failed and which decision answered instead |
cache_hit | invocations | A memoised result was reused |
error | any | What went wrong |
Modes
| Mode | Records |
|---|---|
full (default) | Everything, including every node’s inputs and output |
summary | The node graph, timings and outputs, plus structural annotations. Inputs and data-bearing annotations are dropped |
off | Nothing; Result.Trace is nil |
summary is the mode for a service that wants to see the shape of its
decisions in production without persisting the data that flowed through them.
Redaction
verdict.WithRedactedInputs("Applicant.ssn", "notes")
Named bindings are replaced with [redacted] in the trace. Redaction is a
trace concern only: the decision still sees the real value, so redacting an
input never changes an outcome.
Reading one
verdict eval model.dmn -i inputs.json --trace --quiet | verdict trace --values
loan_approval [evaluation] 630µs
├── Repayment [invocation] 279µs
│ inputs: {"Loan":{"amount":120000,"term_months":240}}
│ output: 996.27
│ └── Monthly Repayment [invocation] 176µs
│ invoked_bkm: "Monthly Repayment"
├── Credit Rating [decisionTable] 143µs
│ hit_policy: "U"
│ matched_rules: ["credit_rating_r1"]
│ output: "excellent"
└── Risk Tier [agentDecision] 106µs
agent_session_ref: "nexus://sessions/9f2c/verdict/risk_tier"
agent_attempts: 1
output: "low"
When an answer is wrong, the trace tells you which node is wrong before you
read a single rule. matched_rules is usually the whole story: either a rule
you did not expect fired, or none did and the table defaulted.
Replay
A trace plus the model and the inputs is enough to reproduce an evaluation
exactly — every deterministic node takes the same path and produces the same
value. ModelHash pins which model ran, so a replay against a changed model
is detectable rather than silently different.
Agent decisions are the exception, and deliberately so: their answers are not a function of their inputs. The trace records the answer and the session reference rather than pretending the call is repeatable. To replay one, feed the recorded answer back in — which is exactly what a mock bridge configured from a trace does.
On the bus
With the Nexus plugin configured with publish_trace: true, every node is
republished on the event bus as it completes, so a dashboard or terminal UI sees
the decision graph filling in rather than waiting for a final answer.
DMN XML and Interoperability
Verdict reads DMN 1.3, 1.4 and 1.5, and writes 1.3 by default.
Writing the newest version it can read would be defensible and wrong: Camunda Modeler and every editor built on dmn-js read 1.3 only. A model no editor opens is not interoperable whatever the version number in it says.
verdict convert model.vdj --to xml --out model.dmn # DMN 1.3, with a diagram
verdict convert model.vdj --to xml --dmn-version 1.5 # newest namespace, few readers
verdict convert model.vdj --to xml --no-diagram # schema-valid, empty canvas
The reader is namespace-tolerant: it matches on local element names, so a document from a tool that declares DMN under an unexpected prefix, or mixes 1.3 and 1.4 namespaces, still loads.
Diagram interchange is generated
A DMN document without DMNDI is perfectly schema-valid and opens as an empty canvas in every dmn-js editor. Verdict therefore generates diagram interchange from the DRG whenever it writes XML: shapes laid out in dependency layers, edges for every requirement, translated into the positive quadrant so the diagram opens on its own content.
Coordinates are generated, never hand-maintained. If you change an example
model’s shape, make diagrams regenerates them.
Three ways the XSD is stricter than the reader
These bit us once already, which is why make validate exists. Verdict’s own
reader accepts all three; the schema — and therefore Camunda — does not.
1. tDefinitions admits foreign attributes only when namespaced. Verdict’s
own metadata travels as verdict:version and verdict:conformanceLevel, never
bare. version and conformanceLevel are not DMN attributes, and a bare one
fails validation for the whole document.
<definitions xmlns="https://www.omg.org/spec/DMN/20191111/MODEL/"
xmlns:verdict="https://github.com/frankbardon/verdict/schema/1.0"
verdict:version="2.1.0"
verdict:conformanceLevel="feel">
2. tDecision’s logic slot admits only DMN’s own expression substitution
group. A foreign element there — an agentDecision, say — fails validation
for the entire file. So an agent decision travels in <extensionElements>
instead, positioned by tDMNElement’s sequence: immediately after
<description>, before <question>.
<decision id="risk_tier" name="Risk Tier">
<description>Assessed from the applicant's notes.</description>
<extensionElements>
<verdict:agentDecision>…</verdict:agentDecision>
</extensionElements>
<question>How risky is this applicant?</question>
<variable name="RiskTier" typeRef="string"/>
<informationRequirement>…</informationRequirement>
</decision>
A standard DMN tool that round-trips this model sees a typed extension element in a slot the schema explicitly reserves for extensions, and either preserves it or warns — rather than refusing the document.
3. tItemDefinition is an xsd:choice. A definition is either a
constrained simple type or a structure — never both. Emitting typeRef and
itemComponent together validates in Verdict’s reader and fails everywhere else.
Element order is fixed
description comes first in every element, because it comes from
tDMNElement. In a decision the order is:
description → extensionElements → question → allowedAnswers → variable → requirements → logic
Getting this wrong is the most common hand-authoring mistake, and the error message from a validator points at the second element, not the misplaced one.
Verify it, do not assert it
“It parses in our reader” is not a claim about anyone else’s tool. Verdict
validates against the real OMG schemas, vendored under
pkg/dmn/xml/testdata/schema/:
make validate
OK examples/content_moderation/content_moderation.dmn
OK examples/loan_approval/loan_approval.dmn
OK examples/pricing/pricing.dmn
The Go test suite runs the same validation (pkg/dmn/xml/schema_test.go) but
skips when xmllint is absent, so the make target fails loudly instead —
an interoperability check that silently skips silently rots. It covers both
directions: the shipped examples as authored, and every example re-written
through the writer, plus a model exported from Camunda.
The schemas are vendored rather than fetched. A validation gate that reaches omg.org is a gate nobody trusts after the third false alarm.
Conformance
| Level | What it admits | Verdict |
|---|---|---|
| 1 | Documentation only | n/a |
| 2 | S-FEEL: decision tables, simple unary tests, literal expressions | Supported; conformance_level: "s-feel" enforces it |
| 3 | Full FEEL and the whole boxed-expression family | Supported; the default |
Declaring level 2 is not decoration — the dialect gate rejects FEEL-only
constructs at load time with VERDICT_LOAD_010, so a model that claims to be
portable S-FEEL is held to it.
Java-bound and PMML function definitions are parsed and preserved through a
round trip, but not executed: calling one returns null and reports
VERDICT_LOAD_007 or VERDICT_LOAD_008 at load, and VERDICT_EVAL_011 when
evaluated. A model that uses them loads and every other decision in it still
runs.
Verdict Decision JSON
VDJ is a lossless JSON projection of a DMN model, for tooling that would rather not touch XML. Every construct maps one-to-one onto the model the evaluator sees, and a document round-trips through DMN XML and back unchanged.
It is a projection, not a second source of truth. There is no behaviour VDJ can express that DMN cannot, and nothing Verdict evaluates differently because a model arrived as JSON. The reader produces the same in-memory model either way, and the same diagnostics.
verdict convert model.dmn --to json > model.vdj # XML → VDJ
verdict convert model.vdj --to xml > model.dmn # VDJ → XML
Use VDJ when a model is generated — by an agent, a rules editor, a config
pipeline. Use DMN XML when a model is edited by a person in a modeller. Both
load; verdict eval does not care which you hand it.
A whole model
{
"vdj": "1.0",
"id": "pricing",
"name": "Pricing",
"namespace": "https://example.com/pricing",
"conformance_level": "feel",
"input_data": [
{ "name": "Order", "variable": { "name": "Order", "type_ref": "Order" } }
],
"item_definitions": [
{ "name": "Order", "components": [
{ "name": "total", "type_ref": "number" },
{ "name": "tier", "type_ref": "string" }
] }
],
"decisions": [
{
"id": "discount",
"name": "Discount",
"question": "What discount applies to this order?",
"required_inputs": ["Order"],
"variable": { "name": "Discount", "type_ref": "number" },
"logic": {
"kind": "decisionTable",
"hit_policy": "UNIQUE",
"inputs": [{ "label": "Tier", "expression": "Order.tier", "type_ref": "string" },
{ "label": "Total", "expression": "Order.total", "type_ref": "number" }],
"outputs": [{ "name": "discount", "type_ref": "number", "default_value": "0" }],
"rules": [
{ "when": ["\"gold\"", ">= 1000"], "then": ["0.15"] },
{ "when": ["\"gold\"", "< 1000"], "then": ["0.10"] },
{ "when": ["\"silver\"", "-"], "then": ["0.05"] }
]
}
}
]
}
Everything except vdj and id is optional. Omitted collections are absent
rather than empty, and an element without an id takes its name as one.
The document
| Field | Meaning |
|---|---|
vdj | Format version. Currently "1.0" |
id | Stable identifier for the model |
name, description | Human labels |
namespace | The DMN namespace the elements belong to |
version | The model’s version — yours, not the format’s |
conformance_level | "s-feel" or "feel". Omit to take the engine default |
expression_language | Default language URI for literal expressions |
exporter, exporter_version | What wrote the file |
Then the element collections: item_definitions, input_data, decisions,
business_knowledge_models, knowledge_sources, decision_services.
Requirements are named, not nested: a decision lists required_inputs,
required_decisions, required_knowledge and authority_requirements as
arrays of IDs. The graph is reconstructed from those names at load, and a name
that matches nothing is VERDICT_LOAD_004, not a silent orphan.
Boxed expressions
A decision’s logic is a discriminated union on kind. The payload fields for
each kind:
kind | Fields |
|---|---|
literalExpression | text, expression_language |
decisionTable | hit_policy, aggregation, orientation, inputs, outputs, rules, annotations |
invocation | called, bindings |
context | entries |
list | elements |
relation | columns, rows |
functionDefinition | parameters, body, function_kind |
agentDecision | agent |
unknown | detail |
Every kind may also carry id and type_ref. Carrying another kind’s field is
an error — see the JSON Schema, which is the only thing that
catches it; the reader ignores it.
unknown is what a document round-tripped through Verdict carries when the
source contained logic Verdict could not interpret. It evaluates to null, is
reported as VERDICT_LOAD_001, and survives the round trip so converting a
model does not delete the part you were about to fix.
Decision tables
{
"kind": "decisionTable",
"hit_policy": "COLLECT",
"aggregation": "SUM",
"inputs": [{ "label": "Amount", "expression": "Claim.amount", "values": "[0..1000000]" }],
"outputs": [{ "name": "fee", "type_ref": "number", "default_value": "0" }],
"annotations": ["Reason"],
"rules": [
{ "when": ["> 500"], "then": ["25"], "annotations": ["large claim surcharge"] }
]
}
hit_policyaccepts the long DMN name (COLLECT) or the single-cell shorthand (C+). A shorthand that names an aggregation setsaggregationtoo; an explicitaggregationfield wins over whatever the shorthand said.whenholds one unary test per input clause, positionally aligned.-(or an empty string) matches anything.thenholds one FEEL expression per output clause. A count that does not match the clause count isVERDICT_LOAD_011.annotationson the table are the column headers;annotationson a rule are its cells, aligned to them.- A single-output table returns the value, not a record (DMN 1.5 §8.3).
Two outputs return a record keyed by
name, which is whynameis required as soon as there is more than one.
values on an input clause and on an output clause carry very different weight:
- Input
valuesbound the input’s domain, and the analyser uses them to decide what counts as a gap. Without them, astringinput has an infinite domain and no gap can be proven. - Output
valuesare the permitted results — and underPRIORITYandOUTPUT ORDERthey are also the ranking, most preferred first. Reversing that list silently reverses the decision.
See Decision Tables for the semantics behind all of this.
Agent decisions
{
"kind": "agentDecision",
"agent": {
"prompt_template": "Classify the risk of this applicant.\n\nNotes: {{.notes}}\nScore: {{.score}}",
"input_bindings": [
{ "name": "notes", "feel": "Applicant.notes" },
{ "name": "score", "feel": "Applicant.credit_score" }
],
"output_type": { "type_ref": "string", "enumeration": ["low", "medium", "high"] },
"validator": "value in [\"low\", \"medium\", \"high\"]",
"policy": {
"max_latency": "PT2S",
"max_retries": 1,
"on_failure": "fallback",
"fallback_decision": "conservative_risk_tier"
}
}
}
input_bindings is the encapsulation boundary: the agent sees the values those
bindings produce and nothing else — never the model context. max_latency is an
ISO-8601 duration and bounds the whole invocation including retries.
Agent Decisions covers the contract in full.
Reading a document Verdict wrote
The writer emits every field it has and omits every field it does not, indents
with two spaces, and orders keys as the struct declares them. It does not sort
keys or normalise whitespace inside FEEL text — a when entry comes back out
exactly as it went in, because the source text of a rule is what a person reads
in a review.
Tolerance
The reader accepts a document containing fields it does not recognise: it
reports VERDICT_LOAD_001 and loads everything else, on the grounds that a
newer VDJ version is a likelier explanation than a typo. A UTF-8 byte-order mark
is stripped rather than choked on.
If you want the strict reading — where a mistyped key is an error — validate against the JSON Schema first. That is the division of labour: the reader is forgiving so a model written for a newer Verdict still runs, and the schema is strict so a model written wrongly is caught before it does.
The VDJ JSON Schema
Verdict publishes a single machine-readable JSON Schema (draft 2020-12) describing a Verdict Decision JSON document. Use it to validate a model before loading it, to get completion and inline errors while writing one by hand, to generate client types, or to gate a pull request in CI.
Where to get it
Three surfaces, one generator (vdj.BuildSchema), byte-identical output:
| Surface | How |
|---|---|
| Docs URL | https://frankbardon.github.io/verdict/vdj-schema.json — the schema’s own $id, so a validator that resolves the identifier fetches the real document |
| CLI | verdict schema prints it to stdout; verdict schema -o vdj-schema.json writes it; verdict schema --validate model.vdj checks a document against it. Offline, no model needed |
| MCP resource | Read verdict://schema (MIME application/json) — see MCP Tools and Resources |
Point an editor at it — most JSON language servers accept a $schema key or a
glob mapping:
{
"$schema": "https://frankbardon.github.io/verdict/vdj-schema.json",
"vdj": "1.0",
"id": "pricing"
}
Or validate from a shell with any draft-2020-12 validator:
verdict schema > vdj-schema.json
check-jsonschema --schemafile vdj-schema.json model.vdj
Structure
The root is a $ref to #/$defs/Document, with every other shape in $defs:
Document— the file itself: the format version, the model’s identity, and the element collections (item_definitions,input_data,decisions,business_knowledge_models,knowledge_sources,decision_services).Decision,BKM,InputData,KnowledgeSource,DecisionService— the DRG elements.Expression— the boxed-expression union, discriminated onkind.TableInput,TableOutput,TableRule,Binding,ContextEntry,InformationItem,ItemDefinition,FunctionItem— the parts they are built from.AgentDecision,AgentBinding,AgentPolicy,TypeSpec— the Verdict extension.
Every object is closed (additionalProperties: false), so a mistyped key is an
error rather than a silently ignored field.
The discriminated union
VDJ’s Expression is a flat union: every kind’s payload sits side by side on
one object, selected by kind. That is convenient to write and to read, and it
is exactly the shape a naive schema fails to constrain — nothing in the struct
itself stops a decisionTable from carrying a list’s elements.
The schema constrains it. For each kind it emits an if/then clause
forbidding the properties belonging to the other kinds:
{
"if": { "properties": { "kind": { "const": "decisionTable" } },
"required": ["kind"] },
"then": { "properties": { "elements": false, "called": false, "…": false } }
}
So this fails validation, where the reader would simply ignore the stray field:
{ "kind": "decisionTable", "elements": [] }
Two kinds additionally require their payload, because the loader reports a hard
error without it rather than degrading: invocation requires called, and
agentDecision requires agent.
How it stays in sync
The schema is generated from three sources, none of them a hand-maintained copy of anything:
- Reflection over the Go structs in
pkg/dmn/vdj— the same typesParsedecodes into. A renamed field, a new field, or a changedomitemptychanges the output. - The model vocabulary registry (
model.All*inpkg/dmn/model/registry.go) supplies every closed enum: boxed-expression kinds, hit policies, COLLECT aggregations, orientations, function kinds, agent failure policies, conformance levels. Adding a hit policy to the engine therefore changes the published contract in the same commit. - A discrimination table for
Expression— the one shape reflection cannot express — mapping each kind to the fields it owns.
Four tests hold the line:
| Test | What it prevents |
|---|---|
TestSchemaGolden | The published file drifting from the generator |
TestSchemaEnumsMatchTheRegistry | An enum advertising a value set the engine no longer has |
TestSchemaCoversEveryExpressionField | A new union field belonging to no kind, silently un-discriminating the union |
TestSchemaAcceptsEveryShippedDocument | The schema rejecting documents Verdict itself writes — every .vdj fixture and every example model projected from DMN XML is validated against it |
The golden carries a trailing // golden-hash: line, so a hand edit to the
contract is detected (TestGoldensNotHandEdited) rather than deployed. The
publishing workflow strips that line before serving the file.
Regenerate after an intentional format change:
go test ./pkg/dmn/vdj/ -run TestSchemaGolden -update
Deliberate boundaries
The schema is faithful, not maximally strict. Two places where it is looser or tighter than the reader, on purpose:
hit_policyenumerates canonical spellings. Both the long DMN names (COLLECT) and the single-cell shorthands (C+) validate. The reader additionally trims and upper-cases, so it acceptscollectwhere the schema does not. The schema describes what a writer should emit, not the full tolerance of the reader.- Unknown fields are rejected here and tolerated there. Verdict’s reader
accepts a document with fields it does not recognise, reporting
VERDICT_LOAD_001rather than refusing to load, because a newer VDJ version is the likelier explanation than a mistake. The schema closes every object instead, because catching a mistyped key in a hand-written model is most of what a schema is for.
The schema also does not express relationships between elements — that a
required_decisions entry names a decision that exists, that a rule’s when
count matches the table’s input count, that the DRG is acyclic. Those are
graph-level properties, checked at load time and reported as
diagnostics. A document can be schema-valid and
still fail to load; run verdict analyze for the rest.
Deployment
Embedded (recommended)
Import the library. A *verdict.Engine is safe across goroutines, holds a
registry of models, and evaluates a full model with a trace in a few hundred
microseconds.
engine, err := verdict.NewEngine(
verdict.WithStrictMode(true),
verdict.WithTracing(verdict.TracingFull),
verdict.WithAgentBridge(bridge),
)
for _, path := range modelPaths {
if _, err := engine.LoadModel(verdict.FromFile(path)); err != nil {
return err // fail startup: a service that is up but missing a model
} // returns 404s the caller may not check
}
Load at startup and fail on error. A decision service that starts without its models is worse than one that does not start.
verdict serve
Run the server when the callers are not Go, or when several services should share one versioned decision surface.
verdict serve -m ./models --listen :7430 --strict
verdict serve -c /etc/verdict/verdict.yaml
There is one binary. verdict serve is a subcommand of the same verdict you
use to evaluate and analyse models locally — same flags, same configuration
file, same bridge construction. A separate daemon would have to duplicate all
three, and the day they drift is the day a model behaves differently in
production than it did in CI.
Models are loaded at startup and the process refuses to start if any of them fails to load, or if there are none to load. A decision server that is up with an empty registry answers every call with a 404 the caller may not check.
For an MCP client that launches Verdict as a subprocess, use verdict mcp
instead — the same MCP surface over stdio, with no listener. See
MCP Tools and Resources.
Twirp
POST /twirp/verdict.v1.Engine/<Method> with a JSON body.
| Method | Purpose |
|---|---|
Evaluate | The model’s top-level decisions |
EvaluateDecision | One decision and its dependencies |
EvaluateService | A named decision service |
LoadModel | Register a model from a document, or from a path under the model root |
ListModels | What is registered |
GetTrace | A retained trace by id |
Analyze | The gap and overlap report |
Explain | A decision’s inputs, dependencies and logic |
curl -s localhost:7430/twirp/verdict.v1.Engine/Evaluate \
-H 'Content-Type: application/json' \
-d '{"model_id":"loan_approval","inputs_json":"{\"Applicant\":{...}}"}'
Models, inputs, outputs and traces cross the wire as JSON-encoded strings rather than being re-modelled in protobuf. There is one source of truth for each of those shapes, and re-modelling them would guarantee drift on the first schema change.
A failed evaluation returns a failed_precondition error carrying its
diagnostics and a trace_id in the error metadata, so the reason survives the
failure.
MCP
/mcp speaks streamable HTTP. Tools:
verdict_list_models— what is loaded, and what inputs each model needsverdict_explain— a decision’s inputs, dependencies and full logicverdict_evaluate/verdict_evaluate_decision— evaluate, with a per-decision summary of which rules firedverdict_analyze— gaps and overlaps
verdict_load_model is off by default and enabled with --mcp-allow-load:
an agent that can load models into a shared engine can also shadow the ones you
deployed.
The right order for an unfamiliar model is list → explain → evaluate. Evaluating first and guessing at input names produces nulls that look like answers.
Health
/healthz and /readyz return the server version and the loaded model IDs. A
server with no models is live but not useful, and the payload says so.
Security
LoadModelby path is refused unless--model-rootis set, and confined to that directory when it is. Without it, a server that reads any path a caller names is a file-disclosure primitive, not a decision engine.- Request bodies are not logged. A decision request is the caller’s data;
logging it by default would quietly turn the server into a data store. Set
--log-level debugfor path-and-status request logging. - Traces are retained in a bounded ring (256 by default). An aged-out trace is a 404, not a leak.
- The server has no authentication of its own. Put it behind whatever your environment already uses; it is designed to sit inside a trust boundary.
Configuration
One YAML vocabulary covers the library, the server and the Nexus plugin. Every
key has a documented default and a file only needs the keys it changes. See
configs/verdict.yaml.
Operating a model
- Version your models.
versionon<definitions>is free-form; semver is recommended. An engine holds several versions of one ID at once, and callers can pin one — which is how you roll a change out gradually. - Content addressing is automatic. Loading identical bytes twice is idempotent and returns the same model, so a reload watcher is cheap.
- Run
verdict analyzein CI. It exits non-zero on an error-severity finding, so a table that develops a hole fails the build rather than a request. - Turn on
strict_modein production, and leave it off while modelling. - Watch
fallback_usedin your traces. A rising rate is an agent availability problem showing up before your users report it.
MCP Tools and Resources
Verdict speaks MCP, so an agent can evaluate decisions, read a decision’s rules and check a model for gaps without a human in the loop.
The catalogue is SDK-agnostic. A tool carries a name, reflected input and output schemas, and a type-erased handler; the go-sdk adapter mounts them, and anything else could. Schemas are reflected from Go struct tags, never hand-written — a hand-written schema drifts from the struct on the first field added.
Tools
| Tool | What it does |
|---|---|
verdict_list_models | What is loaded: versions, the decisions and services each model offers, and which decisions are top-level outputs. Start here. |
verdict_explain | A decision’s inputs, dependencies and full logic — every rule of a table, or the bindings, output type and failure policy of an agent decision |
verdict_evaluate | Evaluate a model’s top-level decisions and return the outputs with a trace |
verdict_evaluate_decision | Evaluate one decision or service and its dependencies. Cheaper, and the trace shows exactly which rules fired |
verdict_analyze | Gaps, overlaps and unreachable rules |
verdict_load_model | Register a model sent by the caller. Off by default — see below |
The right order against an unfamiliar model is always list → explain → evaluate. Evaluating first and guessing at input names produces nulls that look like answers.
verdict_load_model is opt-in (AllowLoad) because an agent that can load
arbitrary models into a shared engine can also shadow the ones an operator
deployed.
A tool that fails reports the error as tool output rather than as a protocol failure, so the model can read what went wrong and correct its next call instead of losing the turn.
Resources
Two static documents, addressable under the verdict:// scheme. They describe
the format and the engine, not any loaded model, so they are safe to expose
whatever the server has been given to evaluate.
| URI | MIME | What |
|---|---|---|
verdict://schema | application/json | The VDJ JSON Schema — the contract for a model document |
verdict://skill | text/markdown | The embedded skill pack: how to write, evaluate and analyse a model, written for a model rather than for a reader of this manual |
Resources rather than tools, deliberately: these are documents an agent reads, not actions it takes, and a client listing its options should not have to weigh them against the tools it might call.
verdict://schema serves the same bytes as verdict schema and as the copy
published at https://frankbardon.github.io/verdict/vdj-schema.json. That
matters when an agent is writing a model: it can fetch the contract in-session
and validate its own output against the same document CI will use.
The skill pack is embedded in the module (skill/SKILL.md), so the guidance an
agent is given always matches the version of the library it is talking to. A
skill pack in a separate repository drifts: the engine gains a hit policy, the
guidance keeps describing the old set, and an agent confidently writes a model
the engine rejects.
Two transports, one surface
verdict mcp -m ./models # stdio: for a client that launches Verdict itself
verdict serve -m ./models # HTTP: MCP at /mcp, alongside Twirp and health
Most MCP clients launch their servers as a subprocess and talk over stdin and
stdout — that is verdict mcp. Configure one with:
{
"command": "verdict",
"args": ["mcp", "--model", "/path/to/models"]
}
verdict serve additionally mounts the same surface at /mcp over streamable
HTTP, for a client that connects to a shared deployment rather than spawning
one. Both build their MCP server through the same constructor, so the tools and
resources are identical either way — TestMCPOverStdioServesTheSameSurface
holds them to it.
Under verdict mcp, stdout is the transport: logs and diagnostics go to
stderr, and the command is not one to pipe into anything but an MCP client.
MCP is served over HTTP unless --no-mcp is passed. verdict_load_model
appears only with --mcp-allow-load, on either transport.
See Deployment for transports, the Twirp surface, health checks and what to think about before exposing any of it.
Nexus integration
Verdict is the deterministic counterpart to Nexus. Nexus reasons in natural language and emits events; Verdict evaluates structured decisions and returns auditable outcomes. Together they cover the hybrid space with an explicit boundary between the halves.
A separate module, on purpose
The integration lives in github.com/frankbardon/verdict/nexus — a distinct
Go module in the same repository.
This is enforcement, not tidiness. A service that imports
github.com/frankbardon/verdict cannot transitively acquire Nexus, its provider
SDKs, or its plugin surface, because the module boundary makes that impossible
rather than merely discouraged. go build ./... at the repository root does not
build nexus/ at all.
The dependency runs one way: this module imports Verdict and Nexus, and neither imports it.
import (
"github.com/frankbardon/verdict/pkg/verdict"
vnexus "github.com/frankbardon/verdict/nexus"
)
Direction 1 — Nexus answers Verdict
The bridge routes agentDecision nodes into Nexus.
bridge, err := vnexus.NewBridge(
vnexus.WithBus(ctx.Bus),
vnexus.WithSession(ctx.Session),
vnexus.WithSessionStrategy(vnexus.PerDecision),
)
engine, err := verdict.NewEngine(verdict.WithAgentBridge(bridge))
Each invocation:
- announces
verdict.decision.requestedon the bus, with the prompt, the bound inputs and the required answer shape; - puts the question to the configured runner;
- parses the answer against the declared type;
- announces
verdict.decision.completedwith the value and the session reference; - writes a transcript into the session workspace, so the call is readable long after the process exits.
The session reference lands in the decision trace, which is what makes an agent decision forensically rather than merely statistically available: given a trace, you can open the exact conversation that produced the value.
Runners
The bridge’s bookkeeping is fixed; how the question is actually answered is not.
LLMRunner (the default) puts one structured-output request to the engine’s
LLM plugin. The declared output type becomes a provider-side JSON Schema, so an
enumerated tier is constrained at the provider rather than corrected afterwards.
Cheap, synchronous, and enough for the classification-shaped questions most
agent decisions ask.
DelegateRunner turns each question into a full sub-agent run: a posture, a
workspace, tool access, a budget and a journal.
bridge, _ := vnexus.NewBridge(
vnexus.WithBus(ctx.Bus),
vnexus.WithSession(ctx.Session),
vnexus.WithRunner(&vnexus.DelegateRunner{
Runtime: delegateRuntime,
Posture: "underwriter",
Overrides: delegate.Overrides{MaxTokens: 4000},
}),
)
This is what “a Nexus session as evaluator” buys over a bare model call. The node is unchanged — same declared type, same validator, same failure policy — but the thing answering it can read files, call tools and take several turns. Swapping runners is a wiring change, not a model change.
Session strategies
| Strategy | Behaviour | When |
|---|---|---|
per-decision (default) | Each agent node gets its own session reference | Almost always. One node’s conversation cannot colour another’s |
per-evaluation | One session per evaluation | Later nodes benefit from seeing what earlier ones were asked. The useful middle ground |
shared | One long-lived session across evaluations | Cheapest and best-informed, and the only strategy where one request’s data can reach another’s prompt. Use it only where that is acceptable |
A model may override the strategy for one node with
<verdict:policy sessionHint="..."/>; an explicit hint always wins, because the
modeller asked for it by name.
Direction 2 — Verdict answers Nexus
The nexus.decision.verdict plugin gives a Nexus agent deterministic
decision-making as a first-class capability.
plugins:
active:
- nexus.decision.verdict
nexus.decision.verdict:
models:
- ./models/loan_approval.dmn
- ./models/pricing.dmn
strict_mode: true
tracing: full
redact_inputs: ["Applicant.ssn"]
publish_trace: true
session_strategy: per-decision
role: reasoning
It loads models at boot, listens for decision.requested, evaluates, and emits
decision.completed with the outputs and the trace:
bus.Emit(vnexus.EventDecisionRequested, vnexus.DecisionRequest{
RequestID: "req-1",
ModelID: "loan_approval",
Inputs: map[string]any{"Applicant": app, "Loan": loan},
})
bus.Subscribe(vnexus.EventDecisionCompleted, func(ev engine.Event[any]) {
res := ev.Payload.(vnexus.DecisionCompleted)
// res.Outputs, res.Trace, res.Diagnostics
})
A failed evaluation emits decision.failed carrying whatever trace was recorded
before the failure — usually the fastest route to the cause.
With publish_trace: true, each node is also republished as
verdict.trace.node as it completes, so a dashboard watches the graph fill in.
The plugin also exposes its engine directly, for a host that would rather skip the bus round trip:
plugin.Engine().Evaluate(ctx, "loan_approval", inputs)
Direction 3 — both at once
An agent receives a task, calls into Verdict for a deterministic classification,
one of Verdict’s agentDecision nodes calls back into a new Nexus session for
a free-form sub-judgement, that session’s answer flows back through Verdict, and
the outcome returns to the original agent.
Every hop is typed and traced. The recursion terminates for two reasons: the DRG
is acyclic, so requirement edges cannot loop; and decision-service invocation
from FEEL — the one path acyclicity does not bound — is capped by max_depth.
Configuration
The plugin reads the same vocabulary as the library’s verdict: block, so one
set of names covers the library, verdict serve and the plugin. See
configs/verdict.yaml.
Development
The module requires a published core version and carries a replace pointing at
the working tree, which is inert for downstream consumers (a replace applies
only to the main module). Build and test it explicitly:
cd nexus && go test ./...
# or, from the repository root:
make test-all
Diagnostic Codes
Every problem the loader, the analyser or the evaluator reports carries a stable, greppable code.
A code never changes meaning. A code that turns out to be wrong is retired and a new one added, never redefined — because logs, dashboards and alert rules outlive the release that emitted them, and a code that silently changes meaning turns a year of history into a lie.
The three families say when the problem was found:
| Prefix | Found during |
|---|---|
VERDICT_LOAD_* | Parsing and preparation. The model may still load |
VERDICT_ANALYZE_* | Static analysis. Found without running anything |
VERDICT_EVAL_* | Evaluation. Found by running this particular input |
Severity is separate from the code: error, warning or info. Errors block
loading in strict mode and always surface; verdict eval exits 2 when an
evaluation succeeds but reported one.
Loading
| Code | Meaning |
|---|---|
VERDICT_LOAD_001 | A boxed expression kind Verdict cannot evaluate. It is preserved and evaluates to null |
VERDICT_LOAD_002 | Unknown hit policy; the table falls back to UNIQUE |
VERDICT_LOAD_003 | A decision has no decision logic; it evaluates to null |
VERDICT_LOAD_004 | A requirement points at an element that does not exist |
VERDICT_LOAD_005 | The DRG is not acyclic |
VERDICT_LOAD_006 | Duplicate element ID |
VERDICT_LOAD_007 | A Java-bound function was parsed but is not executable; calls return null |
VERDICT_LOAD_008 | A PMML function reference was preserved but is not executed |
VERDICT_LOAD_009 | expressionLanguage is not FEEL |
VERDICT_LOAD_010 | A FEEL-only construct in a model that declared S-FEEL |
VERDICT_LOAD_011 | A rule’s entry count differs from the table’s clause count |
VERDICT_LOAD_012 | An agentDecision prompt template does not parse |
VERDICT_LOAD_013 | FEEL text does not parse |
VERDICT_LOAD_014 | A decision service that does not exist, or one declaring no output decisions |
Static analysis
| Code | Meaning |
|---|---|
VERDICT_ANALYZE_001 | A table gap: an input combination no rule covers, and no default output |
VERDICT_ANALYZE_002 | Overlapping rules — an error under U, or under A when the outputs disagree |
VERDICT_ANALYZE_003 | PRIORITY or OUTPUT ORDER without outputValues, so there is no ranking to apply |
VERDICT_ANALYZE_004 | A rule an earlier rule makes unreachable. FIRST only — see below |
VERDICT_ANALYZE_005 | Reserved for an input clause no rule tests. Allocated, not currently emitted |
VERDICT_ANALYZE_004 applies to FIRST alone, and that is not an oversight:
under PRIORITY precedence comes from the outputValues order, not from where
the rule sits in the table, so an earlier rule shadowing a later one is not a
defect there.
Evaluation
| Code | Meaning |
|---|---|
VERDICT_EVAL_001 | No rule matched and the table has no default output |
VERDICT_EVAL_002 | More than one rule matched under UNIQUE |
VERDICT_EVAL_003 | Rules matched under ANY and their outputs disagreed |
VERDICT_EVAL_004 | A required input was not supplied; it evaluated as null |
VERDICT_EVAL_005 | An expression failed to evaluate |
VERDICT_EVAL_006 | A value violated its declared type |
VERDICT_EVAL_007 | An agent decision failed |
VERDICT_EVAL_008 | An agent decision exceeded its latency budget |
VERDICT_EVAL_009 | An agent’s answer was rejected by its validator |
VERDICT_EVAL_010 | A fallback decision was used in place of a failed agent |
VERDICT_EVAL_011 | A Java BKM or an uninterpretable expression yielded null |
VERDICT_EVAL_012 | Recursion depth exceeded |
The ones worth alerting on
Most codes are advisory. These four mean an answer came back that you should not treat as an answer:
VERDICT_EVAL_001— the model was asked something it does not cover. The fix is a rule or a default, not a retry.VERDICT_EVAL_004— an input was missing, so a rule tested null and quietly did not match. This is the one that most often looks like a wrong decision rather than a broken call.VERDICT_EVAL_002/003— the table is ambiguous and the model has been telling you so sinceverdict analyzefirst ran.
VERDICT_EVAL_010 is not a failure — it is the failure policy working — but a
sustained rate of it means the agent path is effectively down and every answer
is coming from the fallback.
Contributing
The full guide is CONTRIBUTING.md
in the repository. This page covers the two things that surprise people.
The update demand
Any change to Verdict’s behaviour, configuration, model vocabulary or public surface must update the corresponding documentation in the same commit.
CLAUDE.md carries the table: change a hit policy and it names the six places
that must change with it; add a boxed expression kind and it names eight. This
is not bureaucracy, it is the only thing that stops the next reader — human or
model — from acting on guidance that stopped being true two releases ago.
If you want to defer a documentation update to a follow-up: the follow-up does not happen.
Commits and branches
Commit subjects follow Conventional Commits
— type(scope): subject, imperative, no trailing full stop — with the scope
naming the package or surface: feel, eval, analyze, dmn/xml, vdj,
cli, server, nexus, docs.
fix(analyze): clip gap probes to a clause's declared input domain
The body carries the why, including the DMN clause when the change touches
spec behaviour. main is protected — branch, open a PR, and let CI run.
The gates
make test # the core module
make test-all # core + the nexus module — the one that covers everything
make test-race
make lint # vet, plus staticcheck if installed
make validate # every example against the DMN 1.3 XSD (needs libxml2)
make examples # load and analyse every example through the built binary
make docs # build this site
go test ./... at the root does not cover nexus/. That is deliberate:
Nexus lives in a separate module so the core cannot depend on it, and the
core’s test run must not require Nexus to be resolvable. make test-all is the
command that covers everything.
make validate fails when xmllint is missing rather than skipping. The Go
test does skip, which is why the make target exists — an interoperability check
that quietly skips quietly rots.
Regenerating what is generated
Nothing in this list is hand-maintained, and editing any of it by hand is a mistake the test suite will catch:
| Artefact | Regenerate with |
|---|---|
| The VDJ JSON Schema | go test ./pkg/dmn/vdj/ -run TestSchemaGolden -update |
| Example diagram interchange | make diagrams |
| The Twirp surface | make proto (generated files are committed) |
Everything in that table is verified by make test: the schema golden by its
hash, the diagrams by make validate, the Twirp surface by the build. Editing
one of them by hand produces a failure, not a change.
The schema golden carries a trailing // golden-hash: line so a hand edit is
detected rather than published.