Skip to content

fystack/programmable-policy-engine

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

10 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Programmable Policy Engine

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

Features

  • 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

Installation

go get github.com/fystack/programmable-policy-engine/policy

Quick Start

The 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.

Interpreting decisions at runtime

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

User-defined actions (custom effects)

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.

Effect precedence (deny-overrides)

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")
}

Mapping snake_case JSON to expr fields

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.

Policy Document Structure

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 in action

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 DENY because the rule matches.
  • Any other transaction yields ALLOW because 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 TestDefaultDenyWhenNoRulesMatch and TestPolicyDefaultEffectAppliedWhenNoRules in policy/engine_test.go.

End-to-End Example

The repository includes a full wallet transaction walkthrough under examples/:

  1. Policy document (examples/policies/transaction.json) denies withdrawals above 100 units and only allows supported stablecoin assets.
  2. Sample payload (examples/data/transaction.json) mirrors a realistic transaction envelope from a wallet API.
  3. Runner (examples/main.go) loads both the policy and payload, applies type validation with WithSchemaDefinition, and prints the decision.

Run the example:

go run ./examples

Example output:

decision=ALLOW policy=withdrawal-override rule=allow_stablecoin_transfer message=Allow outbound transfers for supported stablecoins

Designing Policies

  • Start with a DENY default and add ALLOW rules for trusted scenarios.
  • Use metadata to tag rules with ticket IDs, owners, or severity.
  • Pair WithSchemaDefinition with 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 Decision struct—inspect decision.Matched to see whether a rule (or policy default) fired, and use decision.Policy/decision.Rule plus the message to build your audit trail.

Contributing

Issues and pull requests are welcome. Please include tests for new behavior; the existing suite lives under policy/.

About

AWS-style Policy-as-code for Web3 wallets and treasuries.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages