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
- 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).
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/.