gorege

package module
v1.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Apr 26, 2026 License: MIT Imports: 11 Imported by: 0

README

gorege

CI Coverage Go Report Card Go Reference

gorege

A small Go library for first-match rule evaluation over a fixed tuple of dimensions: access control, feature flags, A/B cohorts, product availability, and similar decisions all map to the same pattern.

Design goals: idiomatic Go, immutable engines safe for concurrent use, explicit semantics (including Explain and dead/shadow rule warnings), and a true BFS-based Closest search for minimum Hamming distance. The API is influenced by recht; gorege adds stronger guarantees and observability.

  • Go 1.26+
  • Zero runtime dependencies (standard library only)
  • JSON configuration via Load / LoadWithOptions / LoadFileWithOptions (.json only)

Install

go get github.com/yplog/gorege

Stability

gorege follows Semantic Versioning. The 1.x line guarantees backward-compatible source for:

  • All exported identifiers under github.com/yplog/gorege.
  • The cmd/gorege CLI subcommand surface and their flags (check, explain, closest, closest-in, partial-check, lint, diff).
  • The JSON config schema under schema/gorege-config.schema.json (existing fields keep their meaning; new optional fields may be added).
  • The --format json output shape of gorege diff.

What is not covered:

  • Internal packages (none currently exist; if added, they will live under internal/).
  • Performance characteristics. Optimizations may change allocation counts, latency, or memory layout in any release.
  • The set of warnings produced for a given engine. New WarningKind values may appear; callers should switch on Kind and ignore unknown kinds.
  • Human-readable text output of gorege diff --format text.

Major-version bumps (2.0, 3.0, …) will use the standard Go module suffix (/v2, /v3) so old code keeps compiling against the old import path.

Quick start

package main

import (
	"fmt"
	"log"

	"github.com/yplog/gorege"
)

func main() {
	e, warnings, err := gorege.New(
		gorege.WithDimensions(
			gorege.Dim("membership", "Gold member", "Regular member", "Guest"),
			gorege.Dim("day", "Mon", "Tue", "Wed", "Thu", "Fri"),
			gorege.Dim("facility", "Swimming pool", "Gym", "Sauna"),
		),
		gorege.WithRules(
			gorege.Allow("Gold member", gorege.Wildcard, gorege.Wildcard),
			gorege.Deny("Guest", gorege.AnyOf("Mon", "Tue"), "Sauna"),
			gorege.Allow(gorege.AnyOf("Guest", "Regular member"), gorege.Wildcard, gorege.Wildcard),
		),
	)
	if err != nil {
		log.Fatal(err)
	}
	for _, w := range warnings {
		log.Printf("rule warning: %s", w)
	}

	ok, err := e.Check("Guest", "Mon", "Sauna")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(ok) // false

	ok, err = e.Check("Guest", "Wed", "Sauna")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(ok) // true
}
Rule shape

Each rule is ALLOW or DENY plus one matcher per dimension (in order):

Matcher in code Meaning
"exact" string Exact value
gorege.AnyOf("a", "b") Any listed value
gorege.Wildcard Any value declared for that dimension

Evaluation is first match wins. If nothing matches, Check returns false. Shorter rules implicitly wildcard trailing dimensions.

Check requires exactly as many arguments as dimensions (ErrArityMismatch otherwise). PartialCheck allows a prefix tuple, including zero values (empty prefix: “could any full tuple still be allowed?”), with Recht-style trailing “unconstrained” behaviour. It returns (bool, error); if you pass more values than dimensions you get ErrArityMismatch instead of a bare false, so overload is not mistaken for denial.

JSON config

LoadFileWithOptions, Load, and LoadWithOptions decode the same schema (call LoadFileWithOptions(path) with no extra options for a plain file load). Extra options (for example WithAnalysisLimit) apply after the JSON-derived dimensions and rules. Example (see also testdata/rules.json):

{
  "dimensions": [
    { "name": "membership", "values": ["Gold member", "Regular member", "Guest"] },
    { "name": "day", "values": ["Mon", "Tue", "Wed", "Thu", "Fri"] },
    { "name": "facility", "values": ["Swimming pool", "Gym", "Sauna"] }
  ],
  "rules": [
    { "action": "ALLOW", "name": "allow-gold", "conditions": ["Gold member", "*", "*"] },
    { "action": "DENY", "name": "deny-guest-sauna-early-week", "conditions": ["Guest", ["Mon", "Tue"], "Sauna"] },
    { "action": "ALLOW", "name": "allow-rest", "conditions": [["Guest", "Regular member"], "*", "*"] }
  ]
}
  • "*" in JSON is a wildcard (same as gorege.Wildcard).
  • A JSON array of strings in a slot is AnyOf.
  • Omit name on a dimension to get an anonymous axis (DimValues-style).
Editor support

Add $schema to your config files for autocomplete and inline validation in any JSON-Schema-aware editor (VS Code, JetBrains, neovim+coc, ...):

{
  "$schema": "https://raw.githubusercontent.com/yplog/gorege/v1.0.0/schema/gorege-config.schema.json",
  "dimensions": [],
  "rules": []
}

Pin the URL to a release tag to avoid breakage from in-progress schema changes on main. The $schema field is ignored by the loader (gorege uses encoding/json decoding into a struct without a $schema field, so unknown keys are silently dropped).

On New, Load, LoadWithOptions, or LoadFileWithOptions, the engine reports warnings for rules that never match any tuple in the Cartesian product (“dead”) or never win first-match (“shadowed”), unless analysis is skipped (see below). Dead detection does not enumerate the product; shadow detection does, subject to a tuple cap. Each Warning includes Kind (WarningKindDead, WarningKindShadowed, or WarningKindAnalysisLimitExceeded) so callers need not parse Message.

Performance note: Shadowed-rule analysis walks the Cartesian product of declared dimension values. With large dimension sets (e.g. 6 dimensions × 20 values = 64 000 000 tuples) this can be slow. The default cap is 100 000 tuples for that pass; use WithAnalysisLimit(n) with New or LoadWithOptions / LoadFileWithOptions to adjust, or pass a negative value to skip analysis entirely. When the cap is exceeded, dead rules are still reported.

Bring Your Own Parser

gorege.Config, gorege.DimensionConfig, and gorege.RuleConfig are exported. To build an engine from YAML, TOML, or another format, decode into these types with your own parser, then call NewFromConfig:

import "gopkg.in/yaml.v3" // in your project; not a gorege dependency

var cfg gorege.Config
if err := yaml.Unmarshal(data, &cfg); err != nil { ... }

e, warnings, err := gorege.NewFromConfig(cfg,
    gorege.WithAnalysisLimit(50_000),
)

gorege uses only encoding/json internally. The yaml:"..." struct tags let you unmarshal with any YAML library on your side while keeping gorege a zero third-party dependency as a library.

API overview

Area Functions
Build New, NewFromConfig, WithDimensions, WithRules, WithTiebreak, WithAnalysisLimit (shadow analysis tuple cap, default 100 000)
Inspect Dimensions, Rules (defensive copies)
Evaluate Check, PartialCheck, Explain
Nearest allow Closest — BFS by Hamming distance from the input; any dimensions may change until an allowed tuple is found. ClosestInonly the selected dimension changes (others fixed); dim is an index or dimension name. Tiebreak (WithTiebreak): leftmost / rightmost / declaration order affects Closest search and reporting.
Config LoadFileWithOptions, Load, LoadWithOptions (.json only)
Types Dimension, Rule, Action, Explanation, ClosestResult, Warning, WarningKind, Config, DimensionConfig, RuleConfig

Engine is immutable and safe to share. For hot reload, load a new engine and swap a sync/atomic.Pointer holding *gorege.Engine.

CLI

go install github.com/yplog/gorege/cmd/gorege@latest
# or: task build-cli  → ./bin/gorege

gorege check path/to/rules.json Guest Wed Sauna   # prints true/false; exit 1 if denied or error
gorege partial-check path/to/rules.json Guest     # prefix [Engine.PartialCheck]: 0..N values (N = #dims); true if some completion could still be allowed
gorege explain path/to/rules.json Guest Wed Sauna # which rule matched (debug); exit 1 on load/arity error only
gorege closest path/to/rules.json Guest Wed Sauna # nearest allowed tuple (BFS); exit 1 if none exists
gorege closest-in path/to/rules.json 2 Guest Wed Sauna   # same, varying only dim index 2
gorege closest-in path/to/rules.json facility Guest Wed Sauna # or dimension name
gorege lint path/to/rules.json                    # dead/shadow warnings (or "ok"); exit 1 if any warnings
gorege diff old.json new.json                     # decision diff over Cartesian product; exit 1 on allow/deny change
gorege diff old.json new.json --limit 250000      # raise the tuple cap
gorege diff old.json new.json --format json       # full transition list as JSON (pipeable to jq)

Where loader warnings go: For check, explain, partial-check, closest, closest-in, and diff, the main result is on stdout (for example true/false or explain fields), so engine load warnings (dead rules, shadowed rules, analysis limit, ...) are printed to stderr as secondary output. lint is the opposite: those warnings are the intended output, so each message is printed to stdout (or ok when there are none), which keeps lint easy to pipe or scrape; load errors still go to stderr.

explain prints matched, allowed, rule_index, rule_name, and action (or a line for implicit deny when no rule matches). Exit code stays 0 when the explanation was computed successfully.

closest walks increasing Hamming distance and may change several dimensions at once (Engine.Closest). closest-in only tries alternate values on one axis (Engine.ClosestIn). Both print found, conditions (JSON array), distance (Hamming distance from the input tuple), dim_index, dim_name, and value for the reported pivot dimension. found: false uses exit code 1. For closest-in, a numeric-only selector is treated as a dimension index; otherwise it is resolved as a name (same as the library).

Examples

The examples/ directory contains self-contained runnable programs demonstrating real-world usage patterns.

Example Scenario API surface
feature_flags/ Feature gate by plan x region LoadFileWithOptions, Check, PartialCheck, ClosestIn
ecommerce_availability/ Product variant availability by region x tier x channel x category Check, Explain, PartialCheck, Closest, ClosestIn, hot reload via atomic.Pointer
http_authz/ RBAC-style HTTP middleware over (role x method x resource) LoadFileWithOptions, Explain, Check, atomic.Pointer hot reload, SIGHUP
cd examples/feature_flags && go run . rules.json
cd examples/ecommerce_availability && go run . rules.json
cd examples/http_authz && go run . rules.json

Development

This repo uses mise for pinned Go (see mise.toml) and Task for common commands:

Task Purpose
task / task test Unit tests
task cover Coverage (profile + merged summary line)
task build-cli Build bin/gorege
task ci gofmt, vet, test, build
task fuzz-load / task fuzz-check Go fuzz (default 5s; e.g. task fuzz-load FUZZTIME=30s)

Fuzz targets live in fuzz_test.go. Normal go test runs each fuzz function once with its seed corpus; use -fuzz=FuzzLoad (etc.) for real fuzzing.

Layout

gorege.go    Engine, New, options
trie.go      Priority Multi-path Trie (always active when dims and rules are present)
rule.go      Rules, matchers, Allow/Deny
dimension.go Dimensions
check.go     Check, PartialCheck, Explain
closest.go   Closest, ClosestIn, tiebreak
conflict.go  Dead / shadow warnings
loader.go    Config types, NewFromConfig, JSON Load / LoadFileWithOptions
result.go    Explanation, ClosestResult, Action helpers
cmd/gorege   CLI
fuzz_test.go Go fuzz targets (Load, Check, …)
testdata/    Example JSON fixtures

Documentation

Index

Constants

View Source
const DefaultAnalysisLimit = 100_000

DefaultAnalysisLimit is the default upper bound on the number of dimension tuples enumerated for shadowed-rule analysis in New. Dead-rule detection does not use this cap. Use WithAnalysisLimit to change the threshold.

Variables

View Source
var (
	// ErrArityMismatch is returned by [Engine.Check] when the number of
	// arguments does not equal the number of dimensions.
	ErrArityMismatch = errors.New("gorege: argument count must match dimension count")

	// ErrRuleTooWide is returned by [New] when a rule has more matchers than dimensions.
	ErrRuleTooWide = errors.New("gorege: rule has more matchers than dimensions")

	// ErrUnknownDimensionValue is returned by [New] when a matcher references a
	// value not present in the corresponding dimension declaration.
	ErrUnknownDimensionValue = errors.New("gorege: matcher references unknown dimension value")

	// ErrInvalidDimension is returned by [Engine.ClosestIn] when dim is not a
	// valid index (int-sized signed/unsigned types) or a known dimension name.
	ErrInvalidDimension = errors.New("gorege: invalid dimension selector")

	// ErrUnsupportedConfigFormat is returned by [LoadFileWithOptions] when the
	// path does not end in .json.
	ErrUnsupportedConfigFormat = errors.New("gorege: unsupported config format (use .json)")
)

Functions

func AnyOf

func AnyOf(vals ...string) anyOf

AnyOf matches if the input equals any of vals.

func Load

func Load(r io.Reader) (*Engine, []Warning, error)

Load decodes JSON from r into an engine. Equivalent to LoadWithOptions with no extra options.

func LoadFileWithOptions

func LoadFileWithOptions(path string, opts ...Option) (*Engine, []Warning, error)

LoadFileWithOptions reads a JSON engine definition from path. The file extension must be .json. It decodes the file and calls LoadWithOptions, appending opts after the JSON-derived WithDimensions and WithRules. Pass no opts for the same behaviour as LoadWithOptions on the file bytes. Use opts to set WithAnalysisLimit, WithTiebreak, or other Option values when loading large configs.

Hot reload: build a new engine with LoadFileWithOptions and swap a sync/atomic.Pointer value holding the active *Engine so readers always load through that pointer.

func LoadWithOptions

func LoadWithOptions(r io.Reader, opts ...Option) (*Engine, []Warning, error)

LoadWithOptions decodes JSON from r into a Config and calls NewFromConfig. Later options override earlier ones for the same setting (e.g. a second WithDimensions replaces dimensions from JSON).

func New

func New(opts ...Option) (*Engine, []Warning, error)

New builds an immutable engine. It validates matchers against dimensions and returns warnings for dead or shadowed rules.

Dead rules are detected without enumerating the Cartesian product. Shadowed rules are detected by walking that product; for large dimension sets this can be expensive. The default upper bound is DefaultAnalysisLimit tuples for shadow analysis only. Use WithAnalysisLimit to raise, lower, or disable (negative value) analysis. When the product exceeds the limit, a Warning with kind WarningKindAnalysisLimitExceeded is returned and shadow analysis is skipped; dead detection still runs.

func NewFromConfig

func NewFromConfig(cfg Config, opts ...Option) (*Engine, []Warning, error)

NewFromConfig builds an engine from a populated Config. It runs the same validation and analysis as Load / LoadFileWithOptions. Callers are responsible for parsing.

opts are applied after WithDimensions and WithRules derived from the config, so options such as WithAnalysisLimit and WithTiebreak can override those settings.

Types

type Action

type Action bool

Action is ALLOW or DENY.

const (
	ActionAllow Action = true
	ActionDeny  Action = false
)

func (Action) String

func (a Action) String() string

String implements fmt.Stringer for Action.

type ClosestResult

type ClosestResult struct {
	Conditions []string
	// Distance is the Hamming distance from the input tuple to Conditions (number
	// of dimensions whose value differs).
	Distance int
	DimIndex int
	DimName  string
	Value    string
}

ClosestResult is returned by Engine.Closest and Engine.ClosestIn when an allowed tuple exists. DimIndex names the primary dimension reported for that hit (see tiebreak strategy). If no allowed combination exists, both methods return a nil pointer.

type Config

type Config struct {
	Dimensions []DimensionConfig `json:"dimensions" yaml:"dimensions"`
	Rules      []RuleConfig      `json:"rules"      yaml:"rules"`
}

Config holds the raw configuration needed to build a gorege engine. Callers are responsible for parsing; this struct only carries data into the engine.

To use YAML or TOML, import a parser in your own project and populate this struct, for example:

var cfg gorege.Config
if err := yaml.Unmarshal(data, &cfg); err != nil { ... }
e, _, err := gorege.NewFromConfig(cfg)

The gorege module itself uses only encoding/json.

type Dimension

type Dimension struct {
	// contains filtered or unexported fields
}

Dimension describes one axis of the decision tuple. Values are the allowed strings for that axis when dimensions are declared.

func Dim

func Dim(name string, values ...string) Dimension

Dim creates a named dimension. The name enables ClosestIn(name, ...) and clearer warnings once those features are wired up.

func DimValues

func DimValues(values ...string) Dimension

DimValues creates an anonymous dimension, addressable only by index.

func (Dimension) Name

func (d Dimension) Name() string

Name returns the dimension name, or empty if anonymous.

func (Dimension) Values

func (d Dimension) Values() []string

Values returns a copy of declared values in declaration order.

type DimensionConfig

type DimensionConfig struct {
	Name   string   `json:"name"   yaml:"name"`
	Values []string `json:"values" yaml:"values"`
}

DimensionConfig describes one dimension axis. If Name is empty, the dimension is anonymous (DimValues semantics).

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

Engine evaluates a frozen rule set. It is safe for concurrent use.

func (*Engine) Check

func (e *Engine) Check(values ...string) (bool, error)

Check evaluates the input tuple with strict arity: len(values) must equal the number of dimensions. First matching rule wins; if none match, false.

func (*Engine) Closest

func (e *Engine) Closest(values ...string) (*ClosestResult, error)

Closest searches for a nearest allowed tuple using breadth-first Hamming distance: distance 1, then 2, … until a candidate passes Engine.Check. Tiebreak controls subset and reporting order; the returned ClosestResult highlights one primary changed dimension consistent with that strategy.

func (*Engine) ClosestIn

func (e *Engine) ClosestIn(dim any, values ...string) (*ClosestResult, error)

ClosestIn restricts the search to a single dimension. dim may be a dimension index (int, int32, int64, uint, uint32, uint64) or a non-empty dimension name (string). Returns nil when no value in that dimension yields an allowed tuple.

func (*Engine) Dimensions

func (e *Engine) Dimensions() []Dimension

Dimensions returns the engine dimensions in order (defensive copy).

func (*Engine) Explain

func (e *Engine) Explain(values ...string) (Explanation, error)

Explain returns which rule matched first, if any. Arity follows Engine.Check. When no rule matches, Matched is false, Allowed is false, and RuleIndex is -1.

func (*Engine) PartialCheck

func (e *Engine) PartialCheck(values ...string) (bool, error)

PartialCheck allows a shorter input prefix (including an empty prefix). Trailing dimensions are unconstrained: a matcher at those positions is treated as satisfied for ALLOW rules and as failed for DENY rules (Recht-style behaviour). The empty prefix means “no values fixed yet”: it is not an arity error (unlike Engine.Check, which requires a full tuple). Semantically it answers whether any completion could still be allowed—for example, after PartialCheck("Guest") asks whether Guest can access for some day, PartialCheck() asks whether anyone can access for some full tuple.

If len(values) is greater than the number of dimensions, it returns ErrArityMismatch so misuse is not conflated with an implicit deny (false, nil).

func (*Engine) Rules

func (e *Engine) Rules() []Rule

Rules returns the rules in first-match order (defensive copy). Matchers are not exported; use [Rule.Name] and Rule.Action for inspection, or rebuild logic via Engine.Check / Engine.Explain.

type Explanation

type Explanation struct {
	Allowed   bool
	RuleIndex int
	RuleName  string
	Action    Action
	Matched   bool
}

Explanation is the outcome of Engine.Explain for a full input tuple.

type Option

type Option func(*engineConfig) error

Option configures New.

func WithAnalysisLimit

func WithAnalysisLimit(n int) Option

WithAnalysisLimit sets the upper bound on tuples scanned for shadowed-rule analysis (Cartesian enumeration) in New. Dead-rule detection does not use this cap.

func WithDimensions

func WithDimensions(dims ...Dimension) Option

WithDimensions sets the ordered dimension tuple. May be empty.

func WithRules

func WithRules(rules ...Rule) Option

WithRules sets rules in first-match order.

func WithTiebreak

func WithTiebreak(s TiebreakStrategy) Option

WithTiebreak sets the TiebreakStrategy used by Engine.Closest. The zero value selects TiebreakLeftmostDim.

type Rule

type Rule struct {
	Name string
	// contains filtered or unexported fields
}

Rule is a single first-match rule. Use Allow / Deny constructors.

func Allow

func Allow(parts ...any) Rule

Allow builds an ALLOW rule. Each part may be:

  • string — exact match
  • WildcardType — match any declared value in that dimension (or any string if the engine has no dimensions)
  • anyOf — match any of the listed values (from AnyOf)

func Deny

func Deny(parts ...any) Rule

Deny builds a DENY rule. Arguments follow the same rules as Allow.

func (Rule) Action

func (r Rule) Action() Action

Action returns the rule's action (ALLOW or DENY).

type RuleConfig

type RuleConfig struct {
	Action     string `json:"action"     yaml:"action"`
	Name       string `json:"name"       yaml:"name"`
	Conditions []any  `json:"conditions" yaml:"conditions"`
}

RuleConfig describes a single rule. Each element of Conditions may be:

  • string — exact match or "*" wildcard
  • []any — AnyOf list (as produced by encoding/json)
  • []string — AnyOf list (as some YAML decoders produce)

type TiebreakStrategy

type TiebreakStrategy int

TiebreakStrategy orders equally-distant candidates in Engine.Closest.

const (
	// TiebreakLeftmostDim prefers the smallest index among changed dimensions
	// when reporting [ClosestResult], and tries subset combinations in
	// increasing lexicographic index order.
	TiebreakLeftmostDim TiebreakStrategy = iota
	// TiebreakRightmostDim prefers the largest changed dimension index and tries
	// subsets with larger indices first.
	TiebreakRightmostDim
	// TiebreakDeclOrder matches [TiebreakLeftmostDim] for this implementation
	// (dimensions are already in declaration order).
	TiebreakDeclOrder
)

func (TiebreakStrategy) GoString

func (t TiebreakStrategy) GoString() string

GoString satisfies fmt.GoStringer for TiebreakStrategy.

type Warning

type Warning struct {
	Kind    WarningKind
	Message string
}

Warning describes a non-fatal issue detected at engine construction time.

func (Warning) String

func (w Warning) String() string

String returns a stable textual form for Warning.

type WarningKind

type WarningKind int

WarningKind classifies a Warning from rule analysis.

const (
	// WarningKindDead means the rule never matches any tuple in the dimension
	// Cartesian product.
	WarningKindDead WarningKind = iota
	// WarningKindShadowed means the rule matches some tuple but never wins
	// first-match against earlier rules.
	WarningKindShadowed
	// WarningKindAnalysisLimitExceeded means shadowed-rule analysis (Cartesian
	// enumeration) was skipped because the dimension value product exceeded the
	// configured limit. Dead-rule detection still runs without this cap.
	WarningKindAnalysisLimitExceeded
)

func (WarningKind) String

func (k WarningKind) String() string

String implements fmt.Stringer for WarningKind.

type WildcardType

type WildcardType struct{}

WildcardType marks a dimension slot as matching any declared value (when dimensions exist) or any input (when the engine has zero dimensions).

var Wildcard WildcardType

Wildcard matches any value in the corresponding dimension. If the engine has no dimensions, it matches any input at that position.

Directories

Path Synopsis
cmd
gorege command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL