toulmin

Stop nesting if-else. Declare rules, declare relationships.
A rule engine for TypeScript, Python, Go, and C. Rules are functions. Exceptions are graph edges. No DSL. No sidecar. No new language.
TypeScript
const isAuthenticated = (ctx, specs) => [ctx.get("user") != null, null]
const isIPBlocked = (ctx, specs) => [blockedIPs.has(ctx.get("ip")), null]
const isInternalIP = (ctx, specs) => [ctx.get("ip")?.startsWith("10."), null]
const isRateLimited = (ctx, specs) => [/* ... */]
const isPremiumUser = (ctx, specs) => [/* ... */]
const isIncidentMode = (ctx, specs) => [/* ... */]
const g = new Graph("api:access")
const auth = g.rule(isAuthenticated)
const blocked = g.counter(isIPBlocked)
const exempt = g.except(isInternalIP)
blocked.attacks(auth)
exempt.attacks(blocked)
const limited = g.counter(isRateLimited) // Tuesday: rate limiting
limited.attacks(auth)
const premium = g.except(isPremiumUser) // Wednesday: premium bypass
premium.attacks(limited)
const incident = g.counter(isIncidentMode) // Thursday: incident override
incident.attacks(premium)
const results = g.evaluate(newContext())
// results[0].verdict > 0: allow
Python (planned)
g = Graph("api:access")
auth = g.rule(is_authenticated)
blocked = g.counter(is_ip_blocked)
exempt = g.except_(is_internal_ip)
blocked.attacks(auth)
exempt.attacks(blocked)
results = g.evaluate(MapContext())
Go
g := toulmin.NewGraph("api:access")
auth := g.Rule(isAuthenticated)
blocked := g.Counter(isIPBlocked)
exempt := g.Except(isInternalIP)
blocked.Attacks(auth)
exempt.Attacks(blocked)
results, _ := g.Evaluate(ctx)
C
rc_graph *g = rc_graph_create("api:access");
rc_rule *auth = rc_graph_rule(g, is_authenticated, NULL);
rc_rule *blocked = rc_graph_counter(g, is_ip_blocked, NULL);
rc_rule *exempt = rc_graph_except(g, is_internal_ip, NULL);
rc_rule_attacks(blocked, auth);
rc_rule_attacks(exempt, blocked);
rc_results *res = NULL;
rc_graph_evaluate(g, ctx, NULL, &res); // res[0].verdict > 0: allow
Requirements evolve. Each day: 2 lines added, nothing else changes. Now the same evolution with if-else:
// Monday
func isAuthenticated(ctx Context, specs Specs) (bool, any) {
req, _ := ctx.Get("req")
return req.(*Req).User != nil, nil
}
// Thursday — 4 levels deep
if user != nil {
if blockedIPs[ip] {
if strings.HasPrefix(ip, "10.") { allow = true }
} else if isRateLimited(ip) {
if isPremium(user) {
if !incidentMode { allow = true } // losing track
}
} else { allow = true }
}
Each day: 2 lines added, nothing else changes. Now the same evolution with if-else:
// Monday
if user != nil {
if blockedIPs[ip] {
if strings.HasPrefix(ip, "10.") {
allow = true
}
} else {
allow = true
}
}
// Tuesday: "add rate limiting" — where does it go?
if user != nil {
if blockedIPs[ip] {
if strings.HasPrefix(ip, "10.") {
allow = true
}
} else if isRateLimited(ip) {
allow = false
} else {
allow = true
}
}
// Wednesday: "premium users bypass rate limit"
if user != nil {
if blockedIPs[ip] {
if strings.HasPrefix(ip, "10.") {
allow = true
}
} else if isRateLimited(ip) {
if isPremium(user) { // 3 levels deep
allow = true
}
} else {
allow = true
}
}
// Thursday: "but not during incident response"
if user != nil {
if blockedIPs[ip] {
if strings.HasPrefix(ip, "10.") {
allow = true
}
} else if isRateLimited(ip) {
if isPremium(user) {
if !incidentMode { // 4 levels, losing track
allow = true
}
}
} else {
allow = true
}
}
toulmin: 2 lines per requirement, structure never changes. if-else: restructure everything, every time.
Install
npm install rulecat # TypeScript
pip install rulecat # Python (planned)
go get github.com/park-jun-woo/toulmin/pkg/toulmin # Go
make -C c build # C — vendor the c/ dir, or `make -C c install` (see c/README.md)
Core Concepts
Rules are functions
// TypeScript
const fn: RuleFunc = (ctx, specs) => [boolean, unknown]
// Python
def fn(ctx: Context, specs: list[Spec]) -> tuple[bool, Any]: ...
// Go
func fn(ctx Context, specs Specs) (bool, any)
ctx = context with get/set for per-request facts (user, IP, context)
specs = fixed criteria at graph declaration via .with() / .with_spec() / .With() (threshold, role name, config)
- Returns
(judgment, evidence). Evidence is any domain-specific type.
const isInRole: RuleFunc = (ctx, specs) => {
const user = ctx.get("user")
if (!user) return [false, null]
const role = (specs[0] as RoleSpec).role
return [user.role === role, user.role]
}
Defeats Graph
Three node types, two edge kinds:
| Node |
Role |
| Rule |
Claim — can be attacked |
| Counter |
Counter-claim — attacks a rule (has own conclusion) |
| Except |
Exception — attacks only (no own conclusion) |
g := toulmin.NewGraph("voting")
auth := g.Rule(isAdult)
criminal := g.Counter(hasCriminalRecord)
expunged := g.Except(isExpunged)
criminal.Attacks(auth) // criminal record attacks adult
expunged.Attacks(criminal) // expungement neutralizes criminal record
Except is the differentiator. "Exceptions to exceptions" cannot be structurally expressed with if-else. In a defeats graph, it's one edge.
Supersedes is a crisp override. Attacks weighs into the h-Categoriser; winner.Supersedes(loser) doesn't. When winner is Active, loser is dropped from the tally outright — marked Excluded in the trace, never traversed — instead of merely having its verdict lowered. Use it when one rule must flatly take precedence over another rather than weigh against it.
Verdict
h-Categoriser computes a continuous value [-1, +1]:
raw(a) = w(a) / (1 + Σ raw(attackers))
verdict = 2 × raw - 1
| Verdict |
Meaning |
| +1.0 |
Confirmed |
| 0.0 |
Undecided |
| -1.0 |
Fully rebutted |
Not binary. The framework interprets:
- Security:
verdict ≤ 0 → deny
- Moderation:
verdict ≤ 0 → block, 0 < v ≤ 0.3 → flag, > 0.3 → allow
- Feature flags:
verdict > 0 → enabled
Level
Orthogonal to the continuous verdict, each rule carries a crisp reporting Level — Unset / Fail / Review — for downstream aggregation (e.g. any Fail → FAIL, else any Review → REVIEW, else PASS). By default a Counter reports Fail and an Except reports Review; rule.Level(l) overrides that (even back to Unset). It surfaces as TraceEntry.Level.
overdrawn := g.Counter(isOverdrawn).Level(toulmin.Fail)
Evaluation Options
ctx := toulmin.NewContext()
ctx.Set("req", req)
results, _ := g.Evaluate(ctx) // default (matrix)
results, _ = g.Evaluate(ctx, toulmin.EvalOption{Trace: true}) // with trace
results, _ = g.Evaluate(ctx, toulmin.EvalOption{Duration: true}) // with duration (trace auto-enabled)
EvalOption controls evaluation behavior: Method (Matrix/Recursive (planned)), Trace (collect TraceEntry), Duration (measure per-rule execution time).
Spec
Spec values must implement the Spec interface:
type Spec interface {
SpecName() string
Validate() error
}
Same function + different spec = different rule. Reuse without closure factories:
g := toulmin.NewGraph("access")
admin := g.Rule(isInRole).With(&RoleSpec{Role: "admin"})
editor := g.Rule(isInRole).With(&RoleSpec{Role: "editor"}).Qualifier(0.8)
nil specs means the rule needs no judgment criteria. Func fields in Spec structs are forbidden — Validate() rejects them.
Trace
EvalOption{Trace: true} tracks each rule's judgment basis:
ctx := toulmin.NewContext()
results, _ := g.Evaluate(ctx, toulmin.EvalOption{Trace: true})
for _, t := range results[0].Trace {
fmt.Printf("%s role=%s activated=%v level=%v excluded=%v evidence=%v\n",
t.Name, t.Role, t.Activated, t.Level, t.Excluded, t.Evidence)
}
Excluded marks a node crisply superseded out of the tally; Level is its crisp reporting level (above). Evidence is the rule function's second return value — return a Fact{Where, Expected, Actual} for a located, quantified violation and recover it downstream via t.Evidence.(toulmin.Fact).
Moderation logs, audit trails, debugging — built into the engine, no extra logging.
Run
Evaluate judges and returns — pure and idempotent. Run judges first, then acts: it
does a full pass, then fires one handler per Active node. A node is Active when its rule
applied and prevailed (activated && verdict > 0) — that is the only event. To inspect
any other node's outcome, read the returned trace: verdict > 0 prevailed, verdict <= 0
defeated, activated == false did not apply.
g.Rule(isAuthenticated).
RunOn(func(self toulmin.TraceEntry, t toulmin.Trace) error {
return audit(self) // self = this handler's own node (ran and prevailed)
})
results, trace, err := g.Run(ctx) // []EvalResult, Trace, error
Handlers fire in registration order; the first handler error stops Run. The handler gets its
own node's entry as self plus a read-only Trace t — the same value Run returns — with
exactly three methods: t.All() (every node's TraceEntry, registration order), t.Get(name)
(one node by short name), and t.Ctx() (this Run's context). self is the first argument,
handed in by the engine, so a handler never has to look up its own row by name (which was
fragile when an entry's name carried a dynamic suffix). Each TraceEntry exposes Claim (Name), Ground (Ground = the ctx
as-is), Backing (Specs), and Verdict — enough to audit, explain, or apply gradient
thresholds without any separate view. There is no direct attacker lookup; reason from
t.All()[i].Verdict. ctx is mutable (side effects propagate); the trace is the judgment
record. If you serialise the trace as JSON, keep ctx to serialisable values (Ground = ctx).
Execution composition
rule.Run(g) declares that when a node is Active, its sub-graph g is Run with the same
ctx — a graph of graphs. Judgment composes upward through Attacks (verdict flows up);
execution composes downward through Run (the sub-graph's verdict stays isolated, only
errors propagate). Execution composition must be a DAG — cycles are rejected up front, depth
is capped at 64.
order := g.Rule(orderPlaced)
order.RunOn(logOrder).Run(notifyGraph) // Active order → Run the notify graph
The RunOn handler + Run(g) execution composition is available across the Go, TypeScript, Python, and C ports.
Framework Packages
Domain-specific frameworks built on the core. Pre-built rule functions and wrappers.
| Package |
Domain |
Key API |
pkg/toulmin |
Core engine |
Graph, EvalOption |
pkg/policy |
Access control (auth, IP, rate limit) |
Guard (net/http middleware) |
pkg/state |
State transitions (FSM) |
Machine.Can, Mermaid() |
pkg/approve |
Multi-step approval workflow |
Flow.Evaluate |
pkg/price |
Price judgment (coupons, membership) |
Pricer.Evaluate |
pkg/feature |
Feature flags (rollout, toggle) |
Flags.IsEnabled |
pkg/moderate |
Content moderation (hate speech, spam) |
Moderator.Review |
pkg/tangl |
Markdown policy language (TANGEUL v0.3): parser, validator, effect-summary analyzer, Go codegen, saga runtime |
parser.Parse, gen.Generate, effects.Closure |
You can use the core without any framework. Writing your own rule functions — like the killer example above — is the most flexible approach.
TANGEUL
TANGEUL v0.3 is a markdown policy language that compiles directly onto the core: no separate runtime, no hand-written glue.
- Markdown is the executable. A
tangl:Cases block — nodes, don't attack edges, do/undo/run execution edges — compiles straight to a toulmin.Graph. Nothing is hand-transcribed.
- Judgment and execution are separated. A
Provides/Internal entry either checks a case (compiles to Graph.Evaluate — pure) or runs one (compiles to Graph.Run — judgment then do/undo side effects via RunOn).
- Saga compensation, built in. Each
do arms its paired undo on a compensation stack the moment it succeeds; a later failure in the same pass replays the stack LIFO, and unrecoverable failures escalate to a *tangl.ReviewError carrying both the cause and the compensation error.
- Effect summary, for audit.
tangl effects statically lists every do/undo an endpoint can trigger — without running it.
- Crisp overrides and levels, in the surface too.
`X` supersedes `Y` excludes Y from the tally when X holds; a trailing , level fail / , level review tags a case's crisp reporting level. Both round-trip through encode/decode in either locale (Korean: `X` 면 `Y` 배제한다, 실패를 보고하는 / 검토를 보고하는).
- One binary source of truth (TANGEUL).
tangl encode absorbs an authored .md surface into a compact, locale-independent .tangeul stream (AST + verbatim prose spans); every other command runs on that stream. tangl decode renders it back to a text surface — byte-identically in mode 1, or regenerated into another locale in mode 2.
- English or Korean surface, same AST.
tangl encode auto-detects en/ko from the document's section headers; tangl decode --locale ko renders any .tangeul as a Korean audit surface for non-developer reviewers (see files/TANGL문법v0.3-한국어.md).
go install github.com/park-jun-woo/toulmin/cmd/tangl@latest
tangl encode policy.md -o policy.tangeul # absorb the .md surface into a .tangeul stream (locale auto-detected)
tangl check policy.tangeul # validate + lint
tangl effects policy.tangeul pay # static do/undo summary reachable from endpoint "pay"
tangl gen policy.tangeul -o pay_gen.go # compile the document to Go source
tangl decode policy.tangeul # render the stream back to text (mode-1, byte-identical)
tangl decode policy.tangeul --locale ko # render the same stream as a Korean audit surface (mode-2)
$ tangl encode pkg/tangl/parser/testdata/transfer.md -o transfer.tangeul
$ tangl check transfer.tangeul
ok
$ tangl effects transfer.tangeul transfer
do bank.withdraw once (can withdraw / balance sufficient)
undo bank.refund (can withdraw / balance sufficient)
do bank.deposit once (can deposit / recipient valid)
do log.TransferComplete (can deposit / recipient valid)
Why toulmin
vs if-else
- Adding a rule:
new.Attacks(existing) one line vs refactoring entire nesting
- Exception handling: edge declaration vs conditions inside conditions
- Audit trail:
Evaluate(EvalOption{Trace: true}) built-in vs separate logging
- Testing: unit test each rule function vs combinatorial explosion
vs OPA/Casbin/Cedar
|
toulmin |
OPA |
Casbin |
Cedar |
| Rule language |
TS/Python/Go/C functions |
Rego (DSL) |
PERM model (config) |
Cedar (DSL) |
| Exception handling |
defeats graph |
rule priority |
policy priority |
forbid/permit |
| Exception of exception |
Except |
none |
none |
none |
| Judgment |
continuous [-1,1] |
allow/deny |
allow/deny |
allow/deny |
| Audit trail |
Trace built-in |
Decision log |
none |
none |
| Dependencies |
Zero |
Go + Rego runtime |
Go |
Rust + FFI |
| Learning curve |
Know your language |
Learn Rego |
Learn PERM model |
Learn Cedar syntax |
Academic Foundation
| Component |
Source |
| 6-element argumentation |
Toulmin (1958) |
| strict/defeasible/defeater |
Nute (1994) |
| h-Categoriser |
Amgoud & Ben-Naim (2013, 2017) |
Testing
RunCases runs table-driven policy tests with zero boilerplate:
func TestAccessPolicy(t *testing.T) {
g := buildAccessGraph()
toulmin.RunCases(t, g, []toulmin.TestCase{
{Name: "admin allowed", Context: &Ctx{Role: "admin"}, Expect: toulmin.VerdictAbove(0)},
{Name: "blocked IP", Context: &Ctx{IP: "blocked"}, Expect: toulmin.VerdictAtMost(0)},
{Name: "unauthenticated", Context: &Ctx{User: nil}, Expect: toulmin.NoResult},
{Name: "partial override", Context: &Ctx{Role: "editor"}, Expect: toulmin.VerdictBetween(0, 0.5)},
})
}
| Expectation |
Condition |
VerdictAbove(v) |
verdict > v |
VerdictAtMost(v) |
verdict <= v |
VerdictBetween(lo, hi) |
lo < verdict <= hi |
NoResult |
no active warrants |
CLI
toulmin evaluate # run example
Used By
- filefunc — LLM-native Go code structure tool. The
validate command uses toulmin defeats graph to handle rule exceptions (F5, F6, etc.).
License
MIT