This repository provides a programmable policy engine that helps wallets and treasury platforms evaluate high-volume transactions against configurable guardrails. Policies are defined as JSON documents and executed with the expr expression language so teams can ship granular business logic without redeploying code.
{
"version": "2024-10-24",
"default_effect": "DENY",
"policies": [
{
"name": "withdrawal-override",
"description": "Control withdrawals based on transaction attributes and asset type",
"rules": [
{
"id": "deny_exceed_amount",
"description": "Exceed allowed amount",
"effect": "DENY",
"condition": "transaction.amount_numeric > 99.9"
},
{
"id": "allow_stablecoin_transfer",
"description": "Allow outbound transfers for supported stablecoins",
"effect": "ALLOW",
"condition": "transaction.asset.symbol in ['USDC', 'USDT']"
}
]
}
]
}Key use cases include:
- Enforcing transaction thresholds (e.g., stop withdrawals above a limit)
- Requiring additional reviews based on user role, network, or asset metadata
- Allowing fast approvals for trusted wallets while denying risky flows by default
- Deterministic evaluations powered by compiled expr programs
- Default effects at document and policy level with a DENY-overrides-ALLOW model
- Effect precedence (optional) for deny-overrides evaluation — highest-priority effect wins regardless of rule order
- Strict schema validation (optional) via
WithSchemaDefinition - Friendly error reporting that surfaces the failing rule and expression issue
- Zero-config loader for JSON policy documents
go get github.com/fystack/programmable-policy-engine/policyThe engine compiles policy documents into executable rules and evaluates them against any Go value (structs, maps, etc.).
package main
import (
"context"
"fmt"
"github.com/fystack/programmable-policy-engine/policy"
)
type TransactionContext struct {
Amount float64
Direction string
Asset struct {
Symbol string
}
}
func main() {
defaultEffect := policy.EffectDeny
doc := policy.Document{
DefaultEffect: &defaultEffect, // deny by default when nothing matches
Policies: []policy.Policy{
{
Name: "withdrawal-override",
Rules: []policy.Rule{
{
ID: "deny_large_withdrawals",
Effect: policy.EffectDeny,
Condition: "Amount > 100",
},
{
ID: "allow_stablecoin_transfer",
Effect: policy.EffectAllow,
Condition: "Asset.Symbol in ['USDC', 'USDT'] && Direction == 'out'",
},
},
},
},
}
engine, err := policy.CompileDocument(doc)
if err != nil {
panic(err)
}
ctx := TransactionContext{
Amount: 90,
Direction: "out",
Asset: struct{ Symbol string }{Symbol: "USDC"},
}
decision := engine.Evaluate(context.Background(), ctx)
fmt.Printf("decision=%s policy=%s rule=%s message=%s\n",
decision.Effect, decision.Policy, decision.Rule, decision.Message)
}You can also construct policy documents by loading JSON files from disk.
Evaluate always returns a Decision—check its fields to decide whether to continue:
decision := engine.Evaluate(context.Background(), ctx)
switch {
case decision.Effect == policy.EffectAllow && decision.Matched:
fmt.Println("ok to continue:", decision.Message)
case decision.Effect == policy.EffectAllow && !decision.Matched:
fmt.Println("allow by document default (no rule matched); double-check audit requirements")
default:
fmt.Println("stop:", decision.Effect, decision.Message)
if decision.Error != nil {
fmt.Println("expr error:", decision.ErrorMessage)
}
}Matched is true when a specific rule (or a policy-level default) triggered. If all policies fall back to the document default, Matched remains false but Effect still reflects the decision (ALLOW or DENY).
The engine ships with two built-in effects (ALLOW and DENY) but accepts any non-empty string as an effect. DENY always short-circuits evaluation; every other effect is treated equally—first match wins. This lets you model domain-specific outcomes without forking or wrapping the engine:
package main
import (
"context"
"fmt"
"github.com/fystack/programmable-policy-engine/policy"
)
// Define your own effects alongside the built-in ones.
const (
EffectAutoApprove policy.Effect = "AUTO_APPROVE"
EffectFlagReview policy.Effect = "FLAG_FOR_REVIEW"
)
func main() {
defaultEffect := policy.EffectDeny
doc := policy.Document{
DefaultEffect: &defaultEffect,
Policies: []policy.Policy{
{
Name: "withdrawal-approval",
Rules: []policy.Rule{
{
ID: "block_large",
Effect: policy.EffectDeny,
Condition: "ValueUSD > 50000",
},
{
ID: "auto_approve_small_whitelisted",
Effect: EffectAutoApprove,
Condition: "IsWhitelisted && ValueUSD < 5000",
},
{
ID: "flag_medium",
Effect: EffectFlagReview,
Condition: "ValueUSD >= 5000 && ValueUSD <= 50000",
},
{
ID: "allow_whitelisted",
Effect: policy.EffectAllow,
Condition: "IsWhitelisted",
},
},
},
},
}
engine, err := policy.CompileDocument(doc)
if err != nil {
panic(err)
}
input := map[string]any{
"ValueUSD": 3500.0,
"IsWhitelisted": true,
}
decision := engine.Evaluate(context.Background(), input)
switch decision.Effect {
case policy.EffectDeny:
fmt.Println("blocked:", decision.Message)
case EffectAutoApprove:
fmt.Println("auto-approved — no manual review needed")
case EffectFlagReview:
fmt.Println("flagged for manual review")
case policy.EffectAllow:
fmt.Println("allowed — requires standard approval")
}
}Because the caller defines what each effect means, you can extend the decision space (e.g., RATE_LIMIT, QUARANTINE, NOTIFY_COMPLIANCE) without changing the engine.
By default the engine uses first-match-wins for non-deny effects. This works well when rule ordering is deliberate, but in custody and treasury contexts a priority mistake can accidentally let an ALLOW slip in before a DENY or REVIEW.
WithEffectPrecedence switches to a highest-priority-wins strategy. You declare the hierarchy once at compile time and the engine enforces it regardless of rule or policy ordering:
const EffectReview policy.Effect = "REVIEW"
engine, err := policy.CompileDocument(doc,
policy.WithEffectPrecedence(policy.EffectDeny, EffectReview, policy.EffectAllow),
)With this configuration:
| Matched effects | Result | Why |
|---|---|---|
DENY + ALLOW |
DENY |
DENY has higher precedence |
REVIEW + ALLOW |
REVIEW |
REVIEW has higher precedence than ALLOW |
ALLOW only |
ALLOW |
Single match, returned as-is |
| Nothing matched | DENY |
Document default kicks in |
The engine still short-circuits when the highest-priority effect (first in the list) is matched — there is no point evaluating further once a DENY is found. All other matches are collected and the winner is selected at the end, so Evaluated on the decision always reflects the full rule count.
Effects not listed in the precedence order are treated as lowest priority. They can still win when they are the only match, but any effect that is in the list will beat them.
decision := engine.Evaluate(context.Background(), input)
switch decision.Effect {
case policy.EffectDeny:
fmt.Println("blocked:", decision.Message)
case EffectReview:
fmt.Println("flagged for manual review")
case policy.EffectAllow:
fmt.Println("approved")
}Use the expr struct tag to expose snake_case JSON fields with the same name inside expressions:
type Transaction struct {
AmountNumeric float64 `json:"amount_numeric" expr:"amount_numeric"`
User struct {
RiskLevel string `json:"risk_level" expr:"risk_level"`
} `json:"user" expr:"user"`
}
// In a rule: "transaction.amount_numeric <= 100 && transaction.user.risk_level == 'low'"When paired with policy.WithSchemaDefinition(Transaction{}), the engine validates both the field names (amount_numeric, risk_level, etc.) and their types at compile time.
| Field | Type | Description |
|---|---|---|
version |
string (optional) | Free-form version tag for tracking document revisions. |
default_effect |
"ALLOW" or "DENY" |
Fallback effect when no rule matches. Defaults to DENY. |
policies[] |
array | Each policy groups related rules. Policies can also specify their own default effect. |
policies[].rules[] |
array | Individual rule definitions with an expression condition and an effect. |
rules[].metadata |
map (optional) | Arbitrary labels for audit trails or analytics. |
Expressions use the expr syntax and must return a boolean. The engine automatically injects your evaluation context (structs, maps) as the root object.
default_effect controls what happens when no rule matches or when a policy needs a fallback decision. Set it on the document to establish a global default, and optionally override it per policy (every policy must contain at least one rule or define its own default_effect):
{
"default_effect": "DENY",
"policies": [
{
"name": "low-risk-fastlane",
"default_effect": "ALLOW",
"rules": [
{
"id": "deny_high_amount",
"effect": "DENY",
"condition": "transaction.amount_numeric > 1000"
}
]
}
]
}With this document:
- A transaction over 1000 units returns
DENYbecause the rule matches. - Any other transaction yields
ALLOWbecause the policy’s local default applies. - If the policy were removed, the engine would fall back to the document-level
DENY. - Policies without rules must specify a
default_effect; otherwise compilation fails. - The behaviour is covered by unit tests such as
TestDefaultDenyWhenNoRulesMatchandTestPolicyDefaultEffectAppliedWhenNoRulesinpolicy/engine_test.go.
The repository includes a full wallet transaction walkthrough under examples/:
- Policy document (
examples/policies/transaction.json) denies withdrawals above 100 units and only allows supported stablecoin assets. - Sample payload (
examples/data/transaction.json) mirrors a realistic transaction envelope from a wallet API. - Runner (
examples/main.go) loads both the policy and payload, applies type validation withWithSchemaDefinition, and prints the decision.
Run the example:
go run ./examplesExample output:
decision=ALLOW policy=withdrawal-override rule=allow_stablecoin_transfer message=Allow outbound transfers for supported stablecoins
- Start with a DENY default and add ALLOW rules for trusted scenarios.
- Use
metadatato tag rules with ticket IDs, owners, or severity. - Pair
WithSchemaDefinitionwith Go structs to catch typos and type errors at compile time. - Keep expressions immutable; prefer new policies over mutating existing ones when you need auditability.
- Every evaluation returns a
Decisionstruct—inspectdecision.Matchedto see whether a rule (or policy default) fired, and usedecision.Policy/decision.Ruleplus the message to build your audit trail.
Issues and pull requests are welcome. Please include tests for new behavior; the existing suite lives under policy/.