Skip to content

SigilA small, typed language for decision logic in Go

Write rules that turn your program's input into a typed decision, such as paging the on-call, rolling out a feature or approving a deploy. Every decision carries a reason and a payload, and every policy is checked against a contract your Go code defines.

Why Sigil?

Most services grow a rule engine by accident, one YAML matcher at a time. Sigil is a language small enough to read on first contact and strict enough to trust with the rules that matter.

YAML rule engine vs. Sigil

The same rules, expressed two ways.

Hand-rolled YAML rules

Stringly typed, checked at runtime, if at all

  • •
    Typos fail open
    A misspelled field resolves to null, and the rule that used it quietly stops matching
  • •
    Order-dependent
    First match wins, so moving a block changes the outcome
  • •
    Templated with text
    Per-team variants come from text/template or YAML anchors, and whitespace breaks them
  • •
    Opaque outcomes
    The result is a bare label, without a reason you can alert on

Sigil

Typed, halting, and explainable

  • •
    Typos fail to compile
    Every field, value, function and payload key is checked against the kind
  • •
    Order-independent
    All rules run; precedence decides between the candidates
  • •
    Typed params and invocation
    Teams invoke a shared policy with values, never with text substitution
  • •
    Reason plus payload
    Every decision names why it happened and carries the data your program acts on

A policy, start to finish

An alert router asks a policy what to do with each alert: page the on-call, drop it, or post it to a channel. Your Go code defines the contract, a policy decides, and your code acts on a typed result.

  1. Define the input and the decisions in Go

    type Input struct {
    	Alert Alert `policy:"alert"`
    	Team  Team  `policy:"team"`
    }
    
    var (
    	Page   = policy.NewDecision[PageData]("page", "critical_alert", "sustained")
    	Drop   = policy.NewDecision[policy.None]("drop", "muted", "not_production")
    	Notify = policy.NewDecision[NotifyData]("notify", "routine", "unrouted")
    )
    
    var Kind = policy.NewKind[Input]("AlertRouting",
    	policy.WithVersion(1),
    	policy.WithEnum(Critical, Warning, Info),
    	policy.WithDecisions(Page, Drop, Notify), // a page beats a drop beats a notification
    	policy.WithDefault(Notify.Reason("unrouted")),
    	// ...
    )
  2. Write the rules

    policy checkout.alerts: AlertRouting@1
    
    let pre_production = alert.labels["env"] in ["staging", "dev"]
    
    when not pre_production and alert.severity == critical {
      page(reason: critical_alert, target: team.oncall)
    }
    
    when not pre_production and alert.severity == warning {
      when alert.firing_for >= 30m {
        page(reason: sustained, target: team.oncall)
      }
    
      notify(reason: routine, channel: team.channel)
    }
    
    when pre_production {
      drop(reason: not_production)
    }
  3. Load once, evaluate per alert, act on the typed result

    p, err := Kind.Load(policies, "checkout.alerts")
    // ...
    res, err := p.Eval(ctx, Input{Alert: alert, Team: team})
    // ...
    switch d := res.Value().(type) {
    case PageData:
    	pageOncall(d.Target, res.Reason) // d is a typed PageData
    case NotifyData:
    	notifySlack(d.Channel, res.Reason)
    case policy.None: // drop
    	log.Info("dropped", "reason", res.Reason)
    }

A warning that has been firing for 45 minutes matches two rules. Both become candidates, and the page wins because the kind ranks it above the notification:

checkout.alerts: page(reason: sustained)
  target = "checkout-primary"

trace: 2 candidates
  * page(reason: sustained)  checkout/alerts.sigil:11:5
      when not pre_production and alert.severity == warning
       and alert.firing_for >= 30m
      target = "checkout-primary"
    notify(reason: routine)  checkout/alerts.sigil:14:3
      channel = "#checkout-alerts"

The tour reads this policy line by line and lets you route alerts through it in the browser.

Not only alerts

Sigil doesn't know what an alert is. The kind says what the input looks like and which decisions exist, and the same rules, lets and precedence decide feature rollouts, discounts, deploy approvals or the roles someone holds. What Sigil is shows five of them side by side, and deploygate is a complete service that decides deploy approvals and access grants with two kinds.

Teams that share a kind can share rules too: a platform team publishes a library of helpers and policies with typed params, product teams invoke them with their own values, and the host can require the rules no team may switch off. Share rules across teams builds that from scratch.

Project status

Sigil is nearly complete. The language, the Go API, composition, the CLI, the language server and editor support are built, and fuzzing covers every layer. Two pieces remain: loading a kind from a file at run time (policy.LoadKind) and host-ordered types such as versions. The roadmap tracks them.

Releases

Select your Platform

Contributors

Your contributions matter. Here's to everyone who's helped bring this to life.