Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 modelInstallation and a First Model
Drive it from a shellThe Command Line
Write a decision table that holds upDecision Tables
Put a model inside a decision safelyAgent Decisions
Audit what an evaluation didTraces
Open a Verdict model in Camunda ModelerDMN XML and Interoperability
Author or generate models as JSONVerdict Decision JSON
Validate a model file in CI or an editorThe VDJ JSON Schema
Run it as a serviceDeployment
Let an agent drive itMCP Tools and Resources
Wire it into NexusNexus Integration
Look up an error codeDiagnostic 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

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
--traceInclude the execution trace in the output
--quietSuppress 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
--jsonEmit the full report as JSON
--strictTreat 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
--jsonEmit 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-diagramOmit 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
--valuesShow 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-mcpDisable the MCP endpoint
--mcp-allow-loadExpose verdict_load_model
--model-root <dir>Directory model loading may read paths from; unset forbids loading by path
--strictRefuse 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.

See MCP Tools and Resources.

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 outputValues list and a defaultOutputEntry.

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

FormMeaning
- or emptyany value
42, "eu", trueequals
< 10, >= 740comparison 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

PolicyBehaviour
U UNIQUEExactly 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 ANYSeveral may match, but they must agree. Disagreement is an error
P PRIORITYSeveral may match; the one whose output ranks highest in outputValues wins
F FIRSTSeveral 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

PolicyBehaviour
C COLLECTA list of every matching rule’s output, in rule order
C+ COLLECT SUMThe sum
C< COLLECT MINThe minimum
C> COLLECT MAXThe maximum
C# COLLECT COUNTThe number of matches
R RULE ORDEREvery match, in rule order
O OUTPUT ORDEREvery 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:

  1. Write rules that partition the input space (best — the totality is visible).
  2. Add a defaultOutputEntry to each output clause.
  3. 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 under A when the rules disagree; informational elsewhere.
  • unreachable rules — under FIRST, a row an earlier row already covers. Not reported for PRIORITY, 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

onFailureBehaviour
error (default)Propagate; the evaluation fails
nullBind null and continue
fallbackEvaluate 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

SignalRuleAgent 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 unrecoverableonly 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.

KeyOnMeaning
hit_policydecision tablesThe policy in shorthand (U, C+, …)
aggregationcollecting tablesSUM, MIN, MAX, COUNT
matched_rulesdecision tablesThe rule IDs that fired
rule_countdecision tablesHow many rules were considered
input_valuesdecision tablesEach input clause’s evaluated value
defaulteddecision tablesNo rule matched; the default was used
invoked_bkminvocationsThe business knowledge model called
agent_promptagent decisionsThe rendered prompt
agent_session_refagent decisionsThe bridge’s identifier for the run
agent_attemptsagent decisionsHow many attempts it took
agent_tokensagent decisionsCost telemetry, when the bridge reports it
fallback_used / fallback_fromagent decisionsThe agent failed and which decision answered instead
cache_hitinvocationsA memoised result was reused
erroranyWhat went wrong

Modes

ModeRecords
full (default)Everything, including every node’s inputs and output
summaryThe node graph, timings and outputs, plus structural annotations. Inputs and data-bearing annotations are dropped
offNothing; 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

LevelWhat it admitsVerdict
1Documentation onlyn/a
2S-FEEL: decision tables, simple unary tests, literal expressionsSupported; conformance_level: "s-feel" enforces it
3Full FEEL and the whole boxed-expression familySupported; 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

FieldMeaning
vdjFormat version. Currently "1.0"
idStable identifier for the model
name, descriptionHuman labels
namespaceThe DMN namespace the elements belong to
versionThe model’s version — yours, not the format’s
conformance_level"s-feel" or "feel". Omit to take the engine default
expression_languageDefault language URI for literal expressions
exporter, exporter_versionWhat 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:

kindFields
literalExpressiontext, expression_language
decisionTablehit_policy, aggregation, orientation, inputs, outputs, rules, annotations
invocationcalled, bindings
contextentries
listelements
relationcolumns, rows
functionDefinitionparameters, body, function_kind
agentDecisionagent
unknowndetail

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_policy accepts the long DMN name (COLLECT) or the single-cell shorthand (C+). A shorthand that names an aggregation sets aggregation too; an explicit aggregation field wins over whatever the shorthand said.
  • when holds one unary test per input clause, positionally aligned. - (or an empty string) matches anything.
  • then holds one FEEL expression per output clause. A count that does not match the clause count is VERDICT_LOAD_011.
  • annotations on the table are the column headers; annotations on 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 why name is required as soon as there is more than one.

values on an input clause and on an output clause carry very different weight:

  • Input values bound the input’s domain, and the analyser uses them to decide what counts as a gap. Without them, a string input has an infinite domain and no gap can be proven.
  • Output values are the permitted results — and under PRIORITY and OUTPUT ORDER they 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:

SurfaceHow
Docs URLhttps://frankbardon.github.io/verdict/vdj-schema.json — the schema’s own $id, so a validator that resolves the identifier fetches the real document
CLIverdict 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 resourceRead 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 on kind.
  • 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:

  1. Reflection over the Go structs in pkg/dmn/vdj — the same types Parse decodes into. A renamed field, a new field, or a changed omitempty changes the output.
  2. The model vocabulary registry (model.All* in pkg/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.
  3. A discrimination table for Expression — the one shape reflection cannot express — mapping each kind to the fields it owns.

Four tests hold the line:

TestWhat it prevents
TestSchemaGoldenThe published file drifting from the generator
TestSchemaEnumsMatchTheRegistryAn enum advertising a value set the engine no longer has
TestSchemaCoversEveryExpressionFieldA new union field belonging to no kind, silently un-discriminating the union
TestSchemaAcceptsEveryShippedDocumentThe 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_policy enumerates canonical spellings. Both the long DMN names (COLLECT) and the single-cell shorthands (C+) validate. The reader additionally trims and upper-cases, so it accepts collect where 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_001 rather 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

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.

MethodPurpose
EvaluateThe model’s top-level decisions
EvaluateDecisionOne decision and its dependencies
EvaluateServiceA named decision service
LoadModelRegister a model from a document, or from a path under the model root
ListModelsWhat is registered
GetTraceA retained trace by id
AnalyzeThe gap and overlap report
ExplainA 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 needs
  • verdict_explain — a decision’s inputs, dependencies and full logic
  • verdict_evaluate / verdict_evaluate_decision — evaluate, with a per-decision summary of which rules fired
  • verdict_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

  • LoadModel by path is refused unless --model-root is 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 debug for 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. version on <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 analyze in 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_mode in production, and leave it off while modelling.
  • Watch fallback_used in 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

ToolWhat it does
verdict_list_modelsWhat is loaded: versions, the decisions and services each model offers, and which decisions are top-level outputs. Start here.
verdict_explainA 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_evaluateEvaluate a model’s top-level decisions and return the outputs with a trace
verdict_evaluate_decisionEvaluate one decision or service and its dependencies. Cheaper, and the trace shows exactly which rules fired
verdict_analyzeGaps, overlaps and unreachable rules
verdict_load_modelRegister 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.

URIMIMEWhat
verdict://schemaapplication/jsonThe VDJ JSON Schema — the contract for a model document
verdict://skilltext/markdownThe 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:

  1. announces verdict.decision.requested on the bus, with the prompt, the bound inputs and the required answer shape;
  2. puts the question to the configured runner;
  3. parses the answer against the declared type;
  4. announces verdict.decision.completed with the value and the session reference;
  5. 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

StrategyBehaviourWhen
per-decision (default)Each agent node gets its own session referenceAlmost always. One node’s conversation cannot colour another’s
per-evaluationOne session per evaluationLater nodes benefit from seeing what earlier ones were asked. The useful middle ground
sharedOne long-lived session across evaluationsCheapest 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:

PrefixFound 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

CodeMeaning
VERDICT_LOAD_001A boxed expression kind Verdict cannot evaluate. It is preserved and evaluates to null
VERDICT_LOAD_002Unknown hit policy; the table falls back to UNIQUE
VERDICT_LOAD_003A decision has no decision logic; it evaluates to null
VERDICT_LOAD_004A requirement points at an element that does not exist
VERDICT_LOAD_005The DRG is not acyclic
VERDICT_LOAD_006Duplicate element ID
VERDICT_LOAD_007A Java-bound function was parsed but is not executable; calls return null
VERDICT_LOAD_008A PMML function reference was preserved but is not executed
VERDICT_LOAD_009expressionLanguage is not FEEL
VERDICT_LOAD_010A FEEL-only construct in a model that declared S-FEEL
VERDICT_LOAD_011A rule’s entry count differs from the table’s clause count
VERDICT_LOAD_012An agentDecision prompt template does not parse
VERDICT_LOAD_013FEEL text does not parse
VERDICT_LOAD_014A decision service that does not exist, or one declaring no output decisions

Static analysis

CodeMeaning
VERDICT_ANALYZE_001A table gap: an input combination no rule covers, and no default output
VERDICT_ANALYZE_002Overlapping rules — an error under U, or under A when the outputs disagree
VERDICT_ANALYZE_003PRIORITY or OUTPUT ORDER without outputValues, so there is no ranking to apply
VERDICT_ANALYZE_004A rule an earlier rule makes unreachable. FIRST only — see below
VERDICT_ANALYZE_005Reserved 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

CodeMeaning
VERDICT_EVAL_001No rule matched and the table has no default output
VERDICT_EVAL_002More than one rule matched under UNIQUE
VERDICT_EVAL_003Rules matched under ANY and their outputs disagreed
VERDICT_EVAL_004A required input was not supplied; it evaluated as null
VERDICT_EVAL_005An expression failed to evaluate
VERDICT_EVAL_006A value violated its declared type
VERDICT_EVAL_007An agent decision failed
VERDICT_EVAL_008An agent decision exceeded its latency budget
VERDICT_EVAL_009An agent’s answer was rejected by its validator
VERDICT_EVAL_010A fallback decision was used in place of a failed agent
VERDICT_EVAL_011A Java BKM or an uninterpretable expression yielded null
VERDICT_EVAL_012Recursion 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 since verdict analyze first 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 Commitstype(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:

ArtefactRegenerate with
The VDJ JSON Schemago test ./pkg/dmn/vdj/ -run TestSchemaGolden -update
Example diagram interchangemake diagrams
The Twirp surfacemake 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.