Online edition for BLOGE
0.9.8-RC1· facts verified 2026-09-15 · 中文
Appendix F — Decision Table
This appendix is the authoritative quick-reference for the decision_table node
type. Every syntax rule, hit-policy guarantee, output shape, and error code listed
here is derived directly from bloge source code and conformance fixtures.
Use this appendix when you need to look up exact semantics without re-reading Chapter 5. For a guided walk-through, start with Chapter 5 — Branches That Decide. For a hands-on exercise, see Lab 6 in Chapter 34.
Syntax Cheat Sheet
decision_table <id>(
<param1> = <expr1>,
<param2> = <expr2>,
...
) [hit=<first|unique|any|collect>] [-> <OutputType>] {
rule (<param>: <predicate>, ...) -> <output>
...
[otherwise -> <output>]
}
hit=defaults tofirstwhen omitted.-> <OutputType>is optional; the compiler infers the type from the first rule's output when absent.- Every
rulenames the parameters it tests inside(...). otherwiseis evaluated only after all explicitruleentries fail (or, forcollect, it is always appended as an additional item — see §Otherwise Semantics).
Minimal example — hit=first with scalar output:
graph creditScreening {
decision_table credit_tier(score = applicant.output.score) hit=first -> String {
rule (score: score >= 750) -> "platinum"
rule (score: 680 <= score < 750) -> "gold"
otherwise -> "rejected"
}
}
Hit Policy Matrix
| Policy | Multiple matches | No match, no otherwise | Exception thrown |
|---|---|---|---|
first | Takes the first match in declaration order | RUNTIME_DECISION_TABLE_NO_MATCH | CODE_NO_MATCH |
unique | Throws RUNTIME_DECISION_TABLE_AMBIGUOUS_MATCH | RUNTIME_DECISION_TABLE_NO_MATCH | CODE_AMBIGUOUS_MATCH / CODE_NO_MATCH |
any | Allowed if all matching outputs are equal (Objects.equals); otherwise RUNTIME_DECISION_TABLE_CONFLICTING_MATCH | RUNTIME_DECISION_TABLE_NO_MATCH | CODE_CONFLICTING_MATCH / CODE_NO_MATCH |
collect | Collects all matches into output.items | Returns { items: [] } — no exception | — |
first example — first rule that fires wins:
graph decisionBasicFirst {
decision_table credit_tier(score = applicant.output.score) hit=first -> String {
rule (score: score >= 750) -> "platinum"
rule (score: 680 <= score < 750) -> "gold"
otherwise -> "rejected"
}
}
unique example — at most one rule may fire; multiple matches are an error:
graph decisionBasicUnique {
decision_table credit_band(score = applicant.output.score) hit=unique -> String {
rule (score: score >= 750) -> "prime"
rule (score: 650 <= score < 750) -> "standard"
otherwise -> "manual"
}
}
any example — multiple rules may fire provided their outputs agree:
graph decisionBasicAny {
decision_table approval(score = ctx.score) hit=any -> String {
rule (score: score >= 700) -> "approved"
rule (score: score >= 750) -> "approved"
otherwise -> "manual"
}
}
collect example — all matching rules contribute to output.items:
graph decisionBasicCollect {
decision_table applicable_discounts(score = applicant.output.score) hit=collect -> String {
rule (score: score >= 700) -> "loyalty"
rule (score: score >= 760) -> "premium"
}
}
Otherwise Semantics
| Policy | otherwise present | otherwise absent |
|---|---|---|
first | Fires when no explicit rule matches | No match → RUNTIME_DECISION_TABLE_NO_MATCH |
unique | Fires when no explicit rule matches | No match → RUNTIME_DECISION_TABLE_NO_MATCH |
any | Fires when no explicit rule matches | No match → RUNTIME_DECISION_TABLE_NO_MATCH |
collect | Always appended as an additional item, regardless of explicit matches | Empty match → returns { items: [] }, no exception |
The
collectasymmetry: Forfirst,unique, andany,otherwiseis a fallback that fires only when nothing else matched. Forcollect, it is an unconditional rule that always appends its output toitems. This distinction is flagged by thedecision-table/collect-otherwiselint rule (INFO severity) so you can decide whether the behaviour is intentional.
Output Shape Reference
Three shapes are available. All rule bodies (including otherwise) in a single
table must use the same shape — mixing shapes is a compile error
(COMPILER_DECISION_TABLE_OUTPUT_SHAPE_MISMATCH).
| Shape | Declaration syntax | Downstream access |
|---|---|---|
| Scalar | -> "value" / -> 3.5 | <node>.output.value |
| Named fields | -> { rate: 3.5, maxTerm: 30 } | <node>.output.rate, <node>.output.maxTerm |
| Collect items | hit=collect + any output | <node>.output.items (a List) |
Multi-output (named fields) example:
graph decisionMultiOutput {
decision_table loan_terms(
score = applicant.output.score,
amount = loan.output.amount
) hit=unique -> { rate: Decimal, maxTerm: Int } {
rule (score: score >= 750, amount: amount <= 500000) -> { rate: 3.5, maxTerm: 30 }
otherwise -> { rate: 6.0, maxTerm: 5 }
}
}
The in Operator
in tests set membership inside a rule predicate. There are two forms.
Form A — Literal array
rule (type: type in ["vip", "enterprise", "gov"]) -> "priority"
- The right-hand side is a literal array:
[lit1, lit2, ...]. - Each element must be a String, Number, or Boolean literal — no expressions, variables, or node paths are allowed.
- Violating this causes a compile error:
COMPILER_DECISION_TABLE_IN_LITERAL_ONLY. - An empty array (
type in []) never matches any input.
Example:
graph decisionTableInStatic {
decision_table customer_tier(type = ctx.type) hit=first -> String {
rule (type: type in ["vip", "enterprise", "gov"]) -> "priority"
otherwise -> "standard"
}
}
Form B — Dynamic parameter reference
rule (type: type in allowed) -> "priority"
- The right-hand side is a single declared parameter name that resolves to
a
Collectionat runtime. - Deep paths like
ctx.allowed,obj.list, ora[0]are not permitted — use the parameter binding in thedecision_table(...)head to forward the collection. - If the parameter resolves to a non-
Collectiontype at runtime, bloge throwsDecisionTableViolationExceptionwith codeRUNTIME_DECISION_TABLE_INVALID_COLLECTION_PARAM. The exception message includes the line and column of theinexpression and the actual runtime type. - A
nullleft-hand value always evaluates tofalse(short-circuit, no exception).
Example:
graph decisionTableInDynamic {
decision_table customer_tier(type = ctx.type, allowed = ctx.allowed) hit=first -> String {
rule (type: type in allowed) -> "priority"
otherwise -> "standard"
}
}
Equality semantics — both forms
Both Form A and Form B use numeric-aware equality internally
(evaluateBinaryOp(EQ_EQ)). This means Integer(700) and BigDecimal(700.0)
are considered equal. You will never get a silent mismatch due to numeric type
differences from different graph nodes.
Common Pitfalls
1. Scope violation — referencing a non-parameter path in a rule
// ❌ COMPILE ERROR: COMPILER_DECISION_TABLE_RULE_SCOPE_VIOLATION
rule (score: applicant.output.score >= 750) -> "prime"
// ✅ bind the value as a parameter first
decision_table credit_band(score = applicant.output.score) hit=unique -> String {
rule (score: score >= 750) -> "prime"
...
}
2. Output shape mismatch
// ❌ COMPILE ERROR: COMPILER_DECISION_TABLE_OUTPUT_SHAPE_MISMATCH
decision_table tier(score = ctx.score) hit=first {
rule (score: score >= 750) -> "prime" // scalar
otherwise -> { label: "manual", flag: true } // named fields — shape mismatch!
}
3. in right-hand side is not a plain parameter name
// ❌ COMPILE ERROR: COMPILER_DECISION_TABLE_IN_INVALID_RIGHT
rule (type: type in ctx.allowed) -> "priority" // deep path not allowed
// ✅ bind it in the parameter list
decision_table tier(type = ctx.type, allowed = ctx.allowed) hit=first -> String {
rule (type: type in allowed) -> "priority"
...
}
4. in right-hand side is not a Collection at runtime
If the parameter bound to the right side of in resolves to a non-Collection
value (e.g., a String or Integer), bloge throws:
RUNTIME_DECISION_TABLE_INVALID_COLLECTION_PARAM
The exception message identifies the exact line, column, and the actual runtime type. Fix the upstream node or graph expression that produces the wrong type.
5. collect + otherwise — always appends, never just falls back
// [INFO] decision-table/collect-otherwise
// The otherwise clause here appends "baseline" to items on EVERY run,
// even when other rules also matched. This may be intentional, but verify.
decision_table discounts(score = ctx.score) hit=collect -> String {
rule (score: score >= 700) -> "loyalty"
otherwise -> "baseline" // always appended
}
Runtime Error Codes
All runtime violations are thrown as DecisionTableViolationException.
The code field carries one of these frozen wire strings:
| Wire string | When thrown |
|---|---|
RUNTIME_DECISION_TABLE_NO_MATCH | first, unique, or any — no rule matched and no otherwise is declared |
RUNTIME_DECISION_TABLE_AMBIGUOUS_MATCH | unique — more than one rule matched |
RUNTIME_DECISION_TABLE_CONFLICTING_MATCH | any — multiple rules matched with different output values |
RUNTIME_DECISION_TABLE_INVALID_COLLECTION_PARAM | in <param> — the parameter does not resolve to a Collection |
These strings are frozen contracts. Copy them verbatim when matching in application error handlers or test assertions.
Compiler Error Codes
The bloge compiler rejects invalid decision table definitions at compile time. Key error identifiers (used in diagnostics and CI lint output):
| Identifier | Cause |
|---|---|
COMPILER_DECISION_TABLE_IN_LITERAL_ONLY | in [...] array contains a non-literal element |
COMPILER_DECISION_TABLE_IN_INVALID_RIGHT | in <expr> right side is not a single parameter name |
COMPILER_DECISION_TABLE_RULE_SCOPE_VIOLATION | A rule predicate references something outside the declared parameters |
COMPILER_DECISION_TABLE_OUTPUT_SHAPE_MISMATCH | Rules use inconsistent output shapes (scalar vs. named) |
COMPILER_DECISION_TABLE_OUTPUT_TYPE_INVALID | A named output type annotation contains a field without the required name: Type form |
COMPILER_DECISION_TABLE_UNKNOWN_PARAMETER | A rule condition names a parameter not declared in the table head |
COMPILER_DECISION_TABLE_DUPLICATE_CONDITION | Two rules have identical conditions |
COMPILER_DECISION_TABLE_PARAM_RESERVED | A parameter name collides with a DSL keyword |
COMPILER_DECISION_TABLE_PARAM_DUPLICATE | Duplicate parameter name in the table head |
COMPILER_DECISION_TABLE_PARAM_NODE_CONFLICT | A parameter name shadows a graph node name |
Minimal Runnable Examples
Example 1 — hit=first with otherwise
graph ex1First {
decision_table credit_tier(score = applicant.output.score) hit=first -> String {
rule (score: score >= 750) -> "platinum"
rule (score: 680 <= score < 750) -> "gold"
otherwise -> "rejected"
}
}
Expected: score=720 → credit_tier.output.value == "gold".
Example 2 — hit=unique — ambiguous-match error path
graph ex2Unique {
decision_table risk_band(score = ctx.score) hit=unique -> String {
rule (score: score >= 650) -> "eligible"
rule (score: score >= 700) -> "preferred"
// No otherwise — if score >= 700 both rules fire → RUNTIME_DECISION_TABLE_AMBIGUOUS_MATCH
}
}
Expected: score=720 → DecisionTableViolationException with
RUNTIME_DECISION_TABLE_AMBIGUOUS_MATCH.
Example 3 — hit=collect with named-field output
graph ex3Collect {
decision_table loan_badges(
score = applicant.output.score,
amount = loan.output.amount
) hit=collect -> { label: String, discount: Decimal } {
rule (score: score >= 700) -> { label: "loyalty", discount: 0.02 }
rule (score: score >= 760) -> { label: "premium", discount: 0.05 }
rule (amount: amount <= 100000) -> { label: "small-loan", discount: 0.01 }
}
}
Expected: score=780, amount=80000 → loan_badges.output.items contains three
entries: loyalty, premium, small-loan.
See Also
- Chapter 5 — Branches That Decide — conceptual introduction, branch-vs-decision-table selection guide
- Chapter 9 — Tooling Workflow — lint recipes for
decision-table/missing-otherwiseanddecision-table/collect-otherwise - Chapter 34 — Labs, Lab 6 —
hands-on exercise covering
unique,collect, and exception paths - Chapter 17 — Putting a Nondeterministic Agent Inside a Deterministic Lifecycle — composing an agent signal with a decision table for auditable policy enforcement