Embed Sigil in a Go service
About 2064 wordsAbout 7 min
2026-09-29
By the end of this guide your Go service defines the DeployApproval kind in Go (the whole kind file is in Kind files), loads the team policies with the platform's guardrails required, and acts on each decision through typed payloads. Everything lives in package policy, import path github.com/spechtlabs/sigil/pkg/policy; the Go API lists every symbol, and the example service is a complete host built this way.
Define the kind
The kind is the contract between your service and the policies. You write it as Go types and declare it once, at package level. Why the contract lives in Go: Kinds as contracts.
Describe the input
Write the input as ordinary structs. Every field tagged policy:"..." becomes an input of the kind, and every nested struct becomes a type. Untagged fields are invisible to policies.
type Input struct {
Release Release `policy:"release"`
Service Service `policy:"service"`
Actor Actor `policy:"actor"`
Environment string `policy:"environment"`
}
type Release struct {
Soak time.Duration `policy:"soak"`
Hotfix bool `policy:"hotfix"`
}
type Service struct {
Name string `policy:"name"`
Tier Tier `policy:"tier"`
Owners []string `policy:"owners"`
Labels map[string]string `policy:"labels"`
}
type Actor struct {
Name string `policy:"name"`
Teams []string `policy:"teams"`
Roles []string `policy:"roles"`
Regions []string `policy:"regions"`
}Go type mapping lists which Go types map to which Sigil types.
Declare the enums
Service.Tier above has type Tier, not string. A field that only ever holds one of a few known values should be an enum, so a policy that compares it with a misspelled value fails to compile instead of never matching. Declare a named string type and a constant for each value:
// Tier is a service's criticality.
type Tier string
const (
TierCritical Tier = "critical"
TierStandard Tier = "standard"
TierInternal Tier = "internal"
)Then register the type with policy.WithEnum(TierCritical, TierStandard, TierInternal) when you build the kind. That declares enum Tier: critical | standard | internal in the kind file, named after the Go type, with the values in the order you pass them. Every field of type Tier becomes a Tier in Sigil, and so do *Tier (an optional), []Tier (a list) and map keys of type Tier. A named string type you don't register stays a plain string.
Policies write the values bare, service.tier == critical, and payload fields of type Tier come back to Go as Tier values.
Go can't stop a caller from putting Tier("critcal") or an empty Tier into the input. Sigil reads it without complaint until a rule looks at service.tier, and then fails the evaluation with a runtime error; the result holds the kind's default, as for any failed evaluation. That's the safe outcome, but it's your caller's mistake reported as a policy failure. Check the value where the request enters your service and answer with a client error there: the example service answers 400 for a tier outside the set. The rules for WithEnum and the panics it can raise are in Enums.
Give each decision a payload
Write one struct per decision that carries data. Leave the reason out: every decision has one implicitly. A payload field can take a default after its name, as a Sigil constant of the field's type:
type ReviewData struct {
Approvers []string `policy:"approvers"`
}
type ApproveData struct {
Bake time.Duration `policy:"bake,default=1h"`
}A decision that carries only a reason uses policy.None as its payload. Each decision of a kind needs its own payload type, because the host tells decisions apart by it. A second reason-only decision declares an empty struct, type AuditorData struct{}, and two decisions with the same fields each declare a named type over one struct, type ReleaseManagerData GrantData. NewKind panics on a shared payload type:
decisions deployer and release_manager share payload type access.GrantData (give each decision its own payload type, like `type ReleaseManagerData GrantData`, which keeps its fields, so a type switch on the result tells them apart)Declare the decisions and their reasons
Declare each decision as a typed handle with policy.NewDecision, listing every reason a rule may give for it:
var (
Deny = policy.NewDecision[policy.None]("deny", "not_eligible", "soak_too_short", "no_rule_matched")
Review = policy.NewDecision[ReviewData]("review", "service_owner")
Approve = policy.NewDecision[ApproveData]("approve", "release_manager", "payments_sre")
)Why reasons are declared names rather than free text: Decisions and reasons.
Name the reasons your Go code uses
Wherever Go code names a reason, to rank it, make it the default or compare a result with it, take a reason handle from the decision:
var (
NotEligible = Deny.Reason("not_eligible")
SoakTooShort = Deny.Reason("soak_too_short")
NoRuleMatched = Deny.Reason("no_rule_matched")
ReleaseManager = Approve.Reason("release_manager")
PaymentsSRE = Approve.Reason("payments_sre")
)Reason panics on a name the decision doesn't declare, so a typo stops the program at init instead of compiling into a comparison that never matches:
policy: decision deny has no reason "no_rule_mached" (did you mean "no_rule_matched"? deny declares: not_eligible, soak_too_short, no_rule_matched)Build the kind
Tie it together with policy.NewKind. The options are the kind file's declarations, written in Go:
var Deploy = policy.NewKind[Input]("DeployApproval",
policy.WithVersion(1),
policy.WithEnum(TierCritical, TierStandard, TierInternal),
policy.WithDecisions(Deny, Review, Approve), // order = precedence
policy.WithReasonPrecedence(NotEligible, SoakTooShort, NoRuleMatched),
policy.WithReasonPrecedence(ReleaseManager, PaymentsSRE),
policy.WithDefault(NoRuleMatched),
policy.WithFunc("split", strings.Split),
)WithEnumdeclares theTierenum from its constants; see Declare the enums.WithDecisionstakes the decisions highest precedence first, so a deny beats a review beats an approval.WithReasonPrecedenceranks one decision's reasons, so two denies, or two approvals, never conflict.WithDefaultis the result when no rule fires, and also what a failed evaluation returns. To return a reason of its own after a conflict, addWithConflict, as Name conflicts in the result shows.WithFuncbinds a host function a policy can call, under the name you give it. The function must be pure, terminate and not panic. To keep a panicking function from taking the service down, see Recover host panics.
For a kind where every decision that fires applies, such as the example service's AccessGrant, pass policy.WithCollect instead of policy.WithDecisions; it may leave out the default. Every option, with its kind file equivalent, is in Kind options.
NewKind panics at init on a Go type it can't map or on a kind that breaks a validity rule, and lists every problem at once. Leaving out both WithVersion and WithDefault gives:
policy.NewKind(DeployApproval): invalid kind:
invalid kind version 0 (the version is a positive integer that changes when the contract does)
kind DeployApproval has no default decision (declare `default <decision>(reason: <reason>)` for the case where no rule fires)The tooling reads the kind as a kind file that your service exports; Build a host binary sets that up.
Load the policies
Compile the policies once, at startup, and keep the compiled *policy.Policy. It's immutable and safe to evaluate from any number of goroutines.
Embed the policy tree in the binary and load it with the kind's Load, naming the root policy and the policies the root must invoke:
//go:embed policies
var policies embed.FS
func load() *policy.Policy[Input] {
p, err := Deploy.Load(policies, "payments.production",
policy.Require("deploy.guardrails"))
if err != nil {
log.Fatal(err) // every diagnostic, with file:line:col and a fix hint
}
return p
}Load reads every .sigil file in the fs.FS into one bundle and resolves imports by the names in the documents' headers, so it doesn't matter how the documents are split into files. policy.Require("deploy.guardrails") fails the load unless payments.production invokes deploy.guardrails unconditionally. Put the requirement wherever the host loads team policies, and name the policies that hold the denies no team may switch off. The rules are in Bundles and Required policies.
A failed load returns a *policy.CompileError whose message quotes the offending lines the way the CLI does:
3:14 (deploy.gate): error: unknown field "teir" on type Service
|
3 | when service.teir == critical {
| ^^^^
= help: did you mean "tier"? Service declares: name, tier, owners, labelsWithout From, the required policy is looked up in the bundle like any other document, which suits an embed.FS built from a reviewed repository, as above. Whenever someone other than the platform team can write to the bundle, for example a ConfigMap, pass the platform's documents with policy.From, so the guardrails can only come from them:
//go:embed platform
var platformFS embed.FS
func loadTeams() (*policy.Policy[Input], error) {
return Deploy.Load(os.DirFS("/etc/sigil"), "payments.production",
policy.Require("deploy.guardrails", policy.From(platformFS)))
}Policies in a ConfigMap walks through that setup, and Trusted sources has the rules. Why it's needed: Why required policies need a trusted source.
To bind the root's params from Go instead of from a team file, pass policy.Params; see Bind params from Go instead. To compile a single source string, for example in a test, use Deploy.Compile(src, "payments.production", opts...), which takes the same options.
Evaluate and act on the result
Call Eval with the request's context and the input. Check the error first, then switch on the result's payload type, the way you'd switch on an error's type:
func decide(ctx context.Context, p *policy.Policy[Input], in Input) error {
res, err := p.Eval(ctx, in)
if err != nil {
return reject(res, err) // res holds deny(reason: no_rule_matched); see Handle failed evaluations
}
switch d := res.Value().(type) {
case ReviewData:
return requestReview(d.Approvers, res.Reason) // d is a typed ReviewData
case ApproveData:
return startRollout(d.Bake)
}
switch res.Why() {
case SoakTooShort:
return retryAfterSoak(res)
case NoRuleMatched:
flagUncovered(p.Name()) // the default: no rule covers this deploy
}
return reject(res, nil)
}- Check
errbefore you switch. A failed evaluation still returns a result, holding the kind's default, so the switches treat it as a deny. Handle failed evaluations shows what to do with the error. res.Value()returns the winner's payload as its Go struct. Each decision has its own payload type, so eachcaseis one decision.res.Why()returns the winner's reason as a handle, and the switch compares it against the handles the way you'd compare an error against sentinels. Use it instead of comparingres.Reasonwith a string, which compiles with a typo in it and never matches.- To check for one decision or reason,
Approve.Match(res)returns the payload and whether it matched, andNoRuleMatched.Is(res)reports whether that's the reason, likeerrors.Asanderrors.Is.
A collecting kind can grant a decision more than once, so there's no single winner to switch on. Range over res.Outcome and switch on each entry's Value() instead. For the example service's AccessGrant kind:
for _, e := range res.Outcome {
switch d := e.Value().(type) {
case access.DeployerData:
grantDeployer(d.TTL, e.Reason) // d is a typed DeployerData
case access.AdminData:
grantAdmin(d.TTL, e.Reason)
}
}Admin.MatchAll(res) returns one decision's entries in outcome order, each with its typed payload. Value, Why, Match and Is panic on a collect all kind without precedence, since there's no single winner to read.
To show why a request got its decision, log or return the trace. Every candidate the rules produced is in res.Trace.Candidates, and Candidate.Location() renders where it came from, through every invocation:
payments/production.sigil:10:3 → deploy/production.sigil:16:5The fields of Result, Trace and Candidate are in Result, and the matching rules in Typed matching.
Next steps
- Handle failed evaluations: fail closed, tell the errors apart, count them and bound evaluation time.
- Policies in a ConfigMap: ship team policies to the service and reload them without an outage.
- Build a host binary: give the
sigilCLI your host functions and export the kind file. - Test your policies: run policy test cases from
go test. - Build policies in Go: write the platform's vocabulary modules in Go, next to the kind, and serve them as a trusted source.
