Decision Tables¶
HoloDeck decision tables are deterministic policy-as-code. A table is a
versioned .dmn.yaml file. It converts named facts into one auditable verdict.
The decision does not call an LLM.
Use a table when policy must stay separate from agent interpretation. An agent can extract a gate-validated object, and a table can decide what that object means. The table ID, version, matched rule, and outputs remain available in the result.
Table format¶
This hardship table is the running example. It calculates a surplus ratio, checks residency, and emits an affordability decision.
# tables/hardship.dmn.yaml
id: affordability # Stable policy identifier
version: 2026-06-01.1 # Required version label
hit_policy: UNIQUE # More than one match is an error
# Full S-FEEL expressions calculate the input columns from named facts.
inputs:
- name: surplus_ratio
expression: (income.net - income.expenses) / income.net
type: number
- name: residency_status
expression: residency.status
type: string
# values constrains the output. For PRIORITY, its order is highest first.
outputs:
- name: affordability
type: string
values:
- affordable
- marginal
- unaffordable
# Each when entry is a unary test against its named input column.
rules:
- when:
surplus_ratio: '>= 0.25'
residency_status: '"verified"'
then:
affordability: affordable
annotation: Comfortable surplus
- when:
surplus_ratio: '[0.10..0.25)'
then:
affordability: marginal
- when:
surplus_ratio: < 0.10
then:
affordability: unaffordable
The second and third rules omit residency_status. An omitted input is an
irrelevant cell and always matches. An explicit - has the same meaning.
Every cell present in one rule must match for that rule to match.
Field reference¶
| Field | Required | Meaning |
|---|---|---|
id |
yes | Table identifier copied to each verdict. |
name |
no | Human-readable table name. |
version |
yes | Version label copied to each verdict. |
hit_policy |
yes | UNIQUE, FIRST, or PRIORITY. |
inputs |
yes | One or more calculated input columns. |
inputs[].name |
yes | Column name used by rules[].when. |
inputs[].expression |
yes | Full S-FEEL expression over the evaluation context. |
inputs[].type |
yes | number, string, boolean, days, or date. |
outputs |
yes | One or more fields emitted in Verdict.outputs. |
outputs[].name |
yes | Output name used by every then and default. |
outputs[].type |
yes | Declared output type. |
outputs[].values |
no | Allowed strings; also the ranking order for PRIORITY. |
rules |
yes | One or more rules, in document order. |
rules[].when |
no | Unary tests keyed by input name. An empty map matches all inputs. |
rules[].then |
yes | One value for every declared output. |
rules[].annotation |
no | Human-readable reason copied to a matched verdict. |
default |
no | Complete output entry used when no rule matches. |
source |
no | Policy authority annotation. It does not affect evaluation. |
provenance |
no | Non-executable authorship and review metadata. |
All model objects reject unknown fields. Input and output names must be unique.
Each when key must name an input. Each then and default must contain
exactly the declared outputs. If an output has values, every emitted value
must be in that list.
Type enforcement
A days input accepts a number or a date difference and converts it to
floating-point days. A number input rejects booleans. Other input types
are declarative until a rule evaluates them, and output types are not
checked at evaluation time. Use values when an output has a fixed set.
The optional provenance fields are generated_by, source, source_doc,
source_sha256, reviewed_by, and reviewed_at. S-FEEL cannot read
provenance, so authorship metadata cannot change a decision.
Input expressions and S-FEEL¶
HoloDeck supports a bounded subset of S-FEEL. Full expressions belong in
inputs[].expression. Rule cells contain unary tests against the calculated
value of one input column.
Supported forms¶
| Form | Example | Where |
|---|---|---|
| Names and dot paths | income.net, residency.status |
input expression |
| Number, string, boolean, and null literals | 12.5, "verified", true, null |
input expression or unary test |
| Arithmetic | (income.net - income.expenses) / income.net |
input expression |
| Comparisons | ratio >= 0.25, status = "verified" |
input expression |
| Boolean logic | a > 1 and b <= 90, not(flag) |
input expression |
| List membership | tier in ["low", "medium"] |
input expression |
| Ranges | ratio in [0.10..0.25) |
input expression |
| Date literals | date("2026-06-01") |
input expression |
| Date comparison and subtraction | application_date - statement_date |
input expression |
| Irrelevant cell | - |
unary test |
| Unary comparison | >= 0.25, != 0, = true |
unary test |
| Unary range | [0.10..0.25), (0.10..0.25] |
unary test |
| Unary equality | "verified", 5 |
unary test |
| Unary negation | not("verified"), not([1..10]) |
unary test |
Ranges support all four endpoint combinations: [a..b], [a..b), (a..b],
and (a..b). Square brackets include the endpoint. Parentheses exclude it.
Negative numbers are valid in expressions, comparisons, and ranges.
String cells need quotes inside the YAML string. For example,
residency_status: '"verified"' compares the input with the string
verified. An unquoted cell such as verified is treated as a variable and
is rejected.
date("2026-06-01") accepts a quoted date literal only. Do not write
date(application_date). Supply date values in the context and subtract the
bare names. A days input converts the resulting datetime.timedelta to a
number of days.
Rejected forms¶
The loader parses every expression and walks an allowlist before the table can run. It rejects these forms:
- Quantifiers, such as
some x in [1, 2] satisfies x > 1. - Conditionals and iteration, such as
if ... then ... elseandfor ... return. - Functions outside the subset, such as
duration("P30D"). - Comma-separated unary tests, including
not("a", "b"). - A rule cell that reads another variable, such as
>= threshold. - Any expression or cell that reads the reserved
provenanceroot. - Malformed syntax.
This allowlist is intentional. The embedded grammar accepts some unsupported
constructs, and unknown functions can return None without an error. Static
rejection prevents an unsupported expression from producing a plausible but
incorrect verdict.
At evaluation time, every referenced name and dot path must resolve. Missing
names, missing attributes, division by zero, and incompatible types fail with
a table, rule, or cell locator. They do not fall through to default.
Hit policies¶
The hit policy says how HoloDeck resolves overlapping rules. Assume these
three rules all match the same context. The output declares
values: [unaffordable, marginal, affordable], in highest-first order:
| Rule order | Output |
|---|---|
| 1 | affordable |
| 2 | marginal |
| 3 | unaffordable |
| Policy | Result |
|---|---|
UNIQUE |
Raises TableEvalError because rules 1, 2, and 3 all matched. |
FIRST |
Returns rule 1 because it appears first in the file. Later rules are not evaluated. |
PRIORITY |
Returns rule 3 because unaffordable ranks first, regardless of rule order. |
For the running example, values ranks affordable above marginal, then
unaffordable. Reverse that list if unaffordable must have the highest
priority. Every output in a PRIORITY table must declare values.
With multiple outputs, HoloDeck compares their priority ranks from left to right. The first declared output dominates. Later outputs break ties. Document order breaks a complete tie.
PRIORITY outputs are string-valued
outputs[].values is a list of strings. Therefore, PRIORITY currently
supports ranked string output values. Use UNIQUE or FIRST for an output
that cannot use a string ranking.
Load and evaluate a table¶
Load the file once, then pass the table and named context to evaluate():
from pathlib import Path
from typing import Any
from holodeck.lib.workflow.table_eval import Verdict, evaluate
from holodeck.models.decision_table import DecisionTable, load_decision_table
TABLE_PATH = Path(__file__).parent / "tables" / "hardship.dmn.yaml"
TABLE: DecisionTable = load_decision_table(TABLE_PATH)
def decide(context: dict[str, Any]) -> Verdict:
return evaluate(TABLE, context)
verdict = decide(
{
"income": {"net": 5000, "expenses": 3000},
"residency": {"status": "verified"},
}
)
assert verdict.outputs == {"affordability": "affordable"}
assert verdict.rule_identity == "rule 1"
load_decision_table() reads YAML with the safe loader and validates the full
model. Read errors, invalid YAML, non-mapping YAML, and structural errors raise
DecisionTableError. Unsupported S-FEEL raises FeelValidationError. Basic
field-shape errors raise Pydantic ValidationError.
evaluate(table, context) calculates every input expression once. It then
tests the rules and applies the hit policy. It returns one Verdict:
| Field | Meaning |
|---|---|
table_id |
ID of the table that made the decision. |
table_version |
Version of that table. |
outputs |
Complete output mapping from the winning rule or default. |
matched_rule_index |
One-based rule index, or None for a default. |
matched_rule_annotation |
Winning rule annotation, if present. |
is_default |
True only when no rule matched and default was used. |
rule_identity |
rule N, or default, no rule matched. |
rule_identity is a convenience property. It is not included in
model_dump(). The other fields can be stored as the decision record.
Default outcomes¶
Add a complete default output when no-match is a valid policy outcome:
default:
affordability: unaffordable
When no rule matches, the verdict has is_default: true, no matched rule
index, and no annotation. If the table has no default, every hit policy
raises TableEvalError. HoloDeck never invents an outcome.
Use a table with an agent workflow¶
Run a table on gate-validated agent output. In the hardship workflow, the gate shape is also the table context shape:
evidence = await workflow.execute_activity(...)
verdict = evaluate(TABLE, evidence.output)
The gate requires income and residency, which are the roots used by the
table input expressions. No mapping layer is necessary. This
gate-shape-equals-table-input-shape pattern makes the policy boundary clear
and keeps invalid model output away from the decision.
For the complete activity, sandbox, and sibling-module loading pattern, see the Temporal Integration guide. Temporal requires the table load in a passed-through sibling module so replay does not perform file I/O. That rule is specific to Temporal; the table format and evaluator are not.