Go API
About 6920 wordsAbout 23 min
2026-09-24
Every exported symbol of the Go packages policy, policytest and cli: its signature, its rules, what it returns and how it fails.
| Package | Import path | Holds |
|---|---|---|
policy | github.com/spechtlabs/sigil/pkg/policy | Kinds, loading, evaluating, results |
policytest | github.com/spechtlabs/sigil/pkg/policytest | Running test files from go test; see Package policytest |
cli | github.com/spechtlabs/sigil/pkg/cli | The sigil command line in a host binary; see Package cli |
The API is pre-1.0, so names and signatures can still change. To embed Sigil step by step, see Embed Sigil in a Go service. The example service uses all three packages. Package build, which writes modules and policies in Go, has its own page: Go builder.
Kinds
NewKind
func NewKind[In any](name string, opts ...Option) *Kind[In]| Parameter | Is |
|---|---|
In | The input struct. Its tagged fields become the kind's inputs, and the structs it reaches become types; see Go type mapping |
name | The kind's name, which policies pin in their headers: policy payments.production: DeployApproval@1 |
opts | The kind options |
- Returns the kind. Build it once, at package level.
- The options must include
WithVersion, and eitherWithDecisionswithWithDefault, orWithCollect. - Panics when the contract can't be exported: a Go type outside the mapping, a tag option outside a payload, or a kind that breaks a validity rule, such as a missing version or default. The panic lists every problem at once.
- A kind that exists can always be exported, and the exported kind file parses back into the same contract.
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),
)policy.NewKind(K): invalid kind:
p: *[]string is a pointer to a slice (use the slice itself; a nil slice already reads as an empty list)
invalid kind version 0 (the version is a positive integer that changes when the contract does)Kind methods
type Kind[In any] struct{ /* unexported fields */ }
func (k *Kind[In]) Name() string
func (k *Kind[In]) Schema() string
func (k *Kind[In]) Load(fsys fs.FS, name string, opts ...LoadOption) (*Policy[In], error)
func (k *Kind[In]) Compile(src, name string, opts ...LoadOption) (*Policy[In], error)
func (k *Kind[In]) Contract() *contract.Kind| Method | Returns |
|---|---|
Name() | The kind's name, as passed to NewKind |
Schema() | The kind as a kind file, in the canonical form and in sigil fmt's canonical style, so it passes sigil fmt --check as it is. It parses back into the same contract |
Load, Compile | A compiled policy; see Loading |
Contract() | The kind model and its binding to the Go types and functions, for this module's tooling, cli and policytest. Its type is internal to the module, so nothing outside it can use it |
A *Kind is immutable and safe for concurrent use. To write Schema() to a file and keep it current, see Build a host binary. For a service that doesn't import the host, sigil gen go generates the Go code that builds the kind from its kind file.
Planned
policy.LoadKind, which would load a kind from its kind file at run time, and an API to bind host functions to such a kind don't exist yet; see Loading a kind at run time.
Kind options
type Option func(*gokind.Options)
func WithVersion(n int) Option
func WithAccepts(n int) Option
func WithEnum[T ~string](values ...T) Option
func WithDecisions(ds ...DecisionRef) Option
func WithCollect(ds ...DecisionRef) Option
func WithPrecedence(ds ...DecisionRef) Option
func WithReasonPrecedence(reasons ...Outcome) Option
func WithExclusive(outcomes ...OutcomeRef) Option
func WithDefault(reason Outcome) Option
func WithConflict(reason Outcome) Option
func WithFunc(name string, fn any) Option
func WithRecoverHostPanics() OptionEach option corresponds to a declaration of a kind file, which Schema() writes out.
| Option | Kind file equivalent | Rules |
|---|---|---|
WithVersion(n) | kind DeployApproval version n | Contract version, bumped by every change to the kind. Required, and at least 1 |
WithAccepts(n) | kind DeployApproval version 3, accepts: n | Oldest version a policy or module may pin. From 1 to the version. Without it, every version is accepted. Raise it with a breaking change; see Versioning |
WithEnum(values...) | enum Tier: critical | standard | internal | One call per enum, named after T, with the values in declaration order; see Enums |
WithDecisions(d...) | decision ..., collect one and precedence ... | Argument order is precedence, highest first. Makes WithDefault required |
WithCollect(d...) | decision ... and collect all | Instead of WithDecisions: every fired decision applies. Argument order is declaration order |
WithPrecedence(d...) | precedence ... in a collect all kind | Ranks a WithCollect kind's decisions, so the outcome is every candidate at the top rank. Must list every decision. On a WithDecisions kind it makes NewKind panic |
WithReasonPrecedence(reasons...) | precedence approve: release_manager > payments_sre | Ranks one decision's reasons, highest first, given as reason handles. Must list every reason of that decision and no other decision's, once per decision |
WithExclusive(outcomes...) | exclusive grant_a, grant_b | Outcomes that can't fire together, each a decision handle or one reason, GrantA.Reason("x"). One set per call, of at least two outcomes. Two of them firing in one evaluation is a *ConflictError, under both collect modes |
WithDefault(reason) | default deny(reason: no_rule_matched) | Result when no rule fires, given as a reason handle. Its payload fields take their defaults, so every payload field of its decision needs a default=. Required with WithDecisions, optional with WithCollect |
WithConflict(reason) | conflict deny(reason: conflicting_rules) | Result of a conflict instead of the default, given as a reason handle. Its payload fields take their defaults, as with WithDefault. Optional, and only with WithDecisions: NewKind panics on a WithCollect kind that sets it |
WithFunc(name, fn) | fn split(string, string) -> list<string> | One call per function. The Sigil signature is derived from fn's type, which returns T or (T, error); the name is always written out. Host functions must be pure, must terminate and must not panic; see Evaluating |
WithRecoverHostPanics() | none; it's host behavior, not contract | A panic in a host function becomes a *RuntimeError instead of unwinding out of Eval. Off by default |
- Options that take several values add up:
WithDecisions(Deny, Review)andWithDecisions(Deny), WithDecisions(Review)declare the same kind, in the same order. - A kind takes
WithDecisionsorWithCollect, never both and never neither. - An error
fnreturns becomes a*RuntimeErrorwhoseErris that error.
A collecting kind without a default:
var Access = policy.NewKind[AccessInput]("AccessGrant",
policy.WithVersion(1),
policy.WithCollect(Read, Write, Admin, CustomerDataWriter, DevEnvWriter),
)Why WithFunc takes the name: Why the language looks like this.
Planned
Host-ordered types, type Version ordered in a kind file and policy.WithOrdered[T](name) in Go, don't exist yet; see Host-ordered types.
Enums
func WithEnum[T ~string](values ...T) Option| Parameter | Is |
|---|---|
T | A named Go type whose underlying type is string. Its name is the enum's name |
values | The enum's values, in declaration order. Each value's string is the name policies write |
- Declares the enum
T. Every field, parameter or result of typeTthatNewKindreaches maps to it; see Go type mapping. - The order is the declaration and printing order. It doesn't rank the values: enums aren't ordered.
Schema()prints the enums before the struct types: first those the input struct, the host functions and the payload structs reach, in the order they first reach them, then the ones nothing reaches, inWithEnumorder.- A payload field of type
Tholds the value's name in the structMatchreturns:Tier("critical").
NewKind panics, listing every problem, when:
Tis unnamed, or registered twice,valuesis empty or repeats a value,- a value isn't an identifier or is a keyword,
- the enum's name is a struct type's name, a built-in type name, or the name of an input, a host function or a decision,
- a value is also the name of an input, a host function or a decision.
type Tier string
const (
TierCritical Tier = "critical"
TierStandard Tier = "standard"
TierInternal Tier = "internal"
)
var Deploy = policy.NewKind[Input]("DeployApproval",
policy.WithVersion(1),
policy.WithEnum(TierCritical, TierStandard, TierInternal),
// ...
)enum Tier: critical | standard | internalA Go value outside the set, such as Tier("") or Tier("critcal"), is a *RuntimeError when a rule reads it, and Eval returns the kind's default with it. A value no rule reads never fails the evaluation. Lists, map keys and optionals check each value they hold when a rule reads it.
3:6 (deploy.gate): service.tier: "critcal" is not a value of TierThat's the error's Error(), for when service.tier == critical on line 3 of a policy compiled with Compile. Its Help is Tier declares: critical, standard, internal.
To declare one step by step, see Declare the enums. Why: Typos in values.
Decisions and reasons
func NewDecision[T any](name string, reasons ...string) Decision[T]
type Decision[T any] struct{ /* unexported fields */ }
func (d Decision[T]) Name() string
func (d Decision[T]) Reasons() []string
func (d Decision[T]) Reason(name string) Outcome
func (d Decision[T]) Match(res *Result) (T, bool)
func (d Decision[T]) MatchAll(res *Result) []Matched[T]
type Outcome struct{ /* unexported fields */ }
func (o Outcome) Decision() string
func (o Outcome) Name() string
func (o Outcome) Is(res *Result) bool
type None struct{}
type DecisionRef interface {
OutcomeRef
Name() string
// unexported methods
}
type OutcomeRef interface{ /* unexported methods */ }| Symbol | Does |
|---|---|
NewDecision[T](name, reasons...) | Declares the decision name with payload struct T and the reasons policies may construct it with. Copies reasons |
Decision[T].Name() | The decision's name as policies write it, "approve" |
Decision[T].Reasons() | The declared reasons, in declaration order, as a copy |
Decision[T].Reason(name) | The reason handle for one declared reason. Panics when the decision doesn't declare name, with the hint the checker gives for the same typo in a policy |
Decision[T].Match, .MatchAll | Typed matching; see Typed matching |
Outcome.Decision() | The name of the handle's decision, "deny" |
Outcome.Name() | The reason's name, "no_rule_matched" |
Outcome.Is(res) | Whether res is exactly that reason; see Typed matching |
None | The payload of a decision that carries only a reason. One decision per kind can use it |
DecisionRef | Any Decision[T], whatever its payload type, so decisions of different payload types pass together to WithDecisions, WithCollect and WithPrecedence. Only Decision implements it |
OutcomeRef | A decision, standing for any of its reasons, or an Outcome for one, as WithExclusive takes them. Only those two implement it |
T's tagged fields are the decision's payload fields, mapped as in Go type mapping. The reason is implicit on every decision and never appears inT.Tis unique within a kind:NewKindrejects two decisions with the same payload type, so a type switch onResult.Valuehas one case per decision. A named type over another payload struct,type ReleaseManagerData GrantData, is a type of its own with the same fields. Why: Each decision has its own Go payload type.- Reasons are plain strings, as
Result.Reasonis. - An
Outcomeis how Go code names a reason: to rank it withWithReasonPrecedence, make it the default withWithDefaultor the conflict outcome withWithConflict, declare it exclusive withWithExclusive, and compare a result against it withIsor in a switch onResult.Why. Outcomeis comparable. Two handles are equal when they name the same decision and reason.- The zero
Outcomenames no reason, andNewKindrejects 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")
)
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")
)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)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)Why reasons are declared names: Decisions and reasons.
Go type mapping
NewKind[In] walks the input struct In by reflection. Every field with a policy:"name" tag becomes an input, every struct type it reaches becomes a type named after the Go type, and every tagged field of those structs becomes a field. Untagged fields and fields tagged policy:"-" are invisible to policies.
| Go | Sigil |
|---|---|
string, bool | string, bool |
int, int64 | int |
float64 | float |
time.Duration | duration |
time.Time | timestamp |
[]T | list<T> |
map[K]T, scalar K | map<K, T> |
*T | ?T |
*[]T, *map[K]T, **T | rejected |
named struct with policy: tags | type |
T registered with WithEnum | the enum T |
*T, []T, map[T]V of an enum T | ?T, list<T>, map<T, V> |
map[K]T of an enum T | rejected: an enum has no zero value for a missing key |
- A named type that isn't a registered enum follows its underlying type: without
WithEnum,type Tier stringmaps tostring. *Structmaps to?Struct, whose fields a policy reads with optional chaining:release?.soak ?? 0s.- A pointer to a slice or a map is rejected; a nil slice or map already reads as an empty list or map.
NewKindrejects anything else, and lists every problem it finds: other integer and float sizes, unsigned integers, channels, funcs, interfaces, anonymous structs, two Go types with the same name, map keys that aren't scalars (bool,int,float,string,durationortimestamp) or enums, and tagged fields that are unexported or embedded.
Payload structs map to decision fields the same way. A payload field's default goes in its tag, after the name:
type ReviewData struct {
Approvers []string `policy:"approvers"`
}
type ApproveData struct {
Bake time.Duration `policy:"bake,default=1h"`
}default=takes a Sigil constant of the field's type.default=is the only tag option, and only payload fields take it. An option on an input's tag or on a field of atypestruct makesNewKindpanic, as a default on atypefield is an error in a kind file.- A decision without a payload uses
policy.None, or an empty struct of its own,type AuditorData struct{}, when another decision of the kind usespolicy.None.
Host function signatures come from the Go function's type: each parameter type maps like a field, and the result is T or (T, error).
The same mapping gives the Go values Params takes and the Go types a host binary decodes inputs into.
Loading
func (k *Kind[In]) Load(fsys fs.FS, name string, opts ...LoadOption) (*Policy[In], error)
func (k *Kind[In]) Compile(src, name string, opts ...LoadOption) (*Policy[In], error)
type Policy[In any] struct{ /* unexported fields */ }
func (p *Policy[In]) Name() string
func (p *Policy[In]) Eval(ctx context.Context, input In) (*Result, error)| Parameter | Is |
|---|---|
fsys | The files to read, such as an embed.FS, os.DirFS or MapFS |
src | One source string, read as a one-file bundle |
name | The root policy's name |
opts | Load options |
Load:
- Reads every
.sigilfile infsysinto one bundle; which files it reads is in Loading files. - Indexes the documents by the names in their headers and compiles the policy
nameas the root. Imports resolve by name within the bundle; see Name resolution. - Checks every document, so a broken document fails the load even when the root never uses it.
Compile:
- Does the same for one source string. The source may hold several documents, each ending where the next header starts, with or without
---between them (see Documents), and imports resolve among them. - The source has no file name, so its positions carry only the line, the column and the document's name:
4:3 (payments.production).
*Policy[In]:
- Both
LoadandCompilereturn one. Name()returns the root's name as its header declares it,"payments.production".- It's immutable and safe for concurrent use.
Evalis under Evaluating.
//go:embed policies
var policies embed.FS
p, err := Deploy.Load(policies, "payments.production", policy.Require("deploy.guardrails"))| Fails when | Error |
|---|---|
| A document doesn't parse, type-check or compile | *CompileError |
| A name is defined twice | *CompileError |
| A document is for another kind | *CompileError: document is for kind AccessGrant, not DeployApproval |
A kind document with the kind's name differs from Schema() | *CompileError: kind document DeployApproval doesn't match the host's kind; see Kind documents in a bundle |
| The root is a module | *CompileError: deploy.common is a module, not a policy |
A Params value or a Require doesn't hold | *CompileError; see Load options |
fsys can't be read (Load only) | The error from reading fsys |
| A trusted source is nil, can't be read, or holds no policies or modules | the trusted source passed to Trusted holds no policies or modules, or ... is nil, or ... couldn't be read with the error from reading it |
Compile errors
type CompileError struct {
Diagnostics []Diagnostic
// unexported fields
}
func (e *CompileError) Error() string
type Diagnostic struct {
Message string // what's wrong, on one line
Help string // how to fix it; empty when there's no obvious fix
Position Position // start of the offending source; unknown for a rule of the kind with no source
End Position // just after the offending source
}Diagnosticsholds every problem found, in source order, each with its position, the document it's in and a fix hint.Error()quotes the offending lines the way the CLI does; see Error messages.
A policy compiled from a string:
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, labelsMapFS
func MapFS(files map[string]string) fs.FS- Returns an in-memory
fs.FSwith each key as a file name and each value as its contents. - Keys need the
.sigilextension to be loaded, like any other file. - Copies the contents, so later changes to
filesdon't affect the result.
To load a ConfigMap read through the Kubernetes API, see Policies in a ConfigMap.
Load options
type LoadOption interface{ /* unexported methods */ }
type Params map[string]any
func Require(name string, opts ...RequireOption) LoadOption
func Trusted(fsys fs.FS) LoadOption
type RequireOption interface{ /* unexported methods */ }
func From(fsys fs.FS) RequireOption| Option | Does |
|---|---|
Params{...} | Binds the root policy's params from Go |
Require(name, ...) | Requires the root policy to invoke the named policy unconditionally |
From(fsys) | Inside Require: takes the required policy, and everything it uses, from a trusted source |
Trusted(fsys) | Adds a trusted source without requiring a policy from it |
LoadOption is implemented by Params, Require and Trusted; RequireOption only by From.
Params
- Maps a param name to a Go value of the shape
NewKindaccepts for the param's type: astringforstring, a[]stringforlist<string>, atime.Durationforduration, a[]Tierforlist<Tier>, and so on. An enum takes its own Go type: astringor[]stringis rejected, and a value outside the enum is a compile error. - Values are type-checked against the root's
paramdeclarations and theirminandmaxat compile time, as invocation arguments are. - Several
Paramsoptions merge; a later value wins for the same name. - Compile errors: a param the root doesn't declare, a value of the wrong type or outside the bounds, and a param without a default that
Paramsleaves unbound.
p, err := Deploy.Load(policies, "deploy.gate",
policy.Params{
"approvers": []string{"payments-leads"},
"min_soak": 4 * time.Hour,
},
policy.Require("deploy.guardrails"),
)Require
- Fails the load unless the root reaches the named policy through top-level invocations only, with no
whenon the path. The rules are in Required policies. - The
*CompileErrorpoints at the gated call, or at the root's header when the call is missing. - Repeat it to require several policies.
- Without
From, the name is looked up in the bundle like any other document. - Takes no bounds of its own; a required policy bounds its own params with
minandmax. sigil checkruns the same check for the policies--requireor the configuration file requires; seesigil check.
From
- Names the trusted source a required policy must come from. The loader reads it as its own bundle, separate from the one passed to
Load. - Several
Requireoptions may name the same source, which is then read once. - A nil source, or one that holds no policy or module, fails the load.
- The rules for what resolves where, which names are reserved and when two sources are the same are in Trusted sources.
p, err := Deploy.Load(policy.MapFS(cm.Data), "payments.production",
policy.Require("deploy.guardrails", policy.From(platformFS)))Why From exists: Why required policies need a trusted source.
Trusted
- Adds a trusted source, such as the vocabulary modules a platform ships, without requiring any policy from it.
- Reads the source into the same trusted bundle as the sources
Fromnames. Its documents resolve before the bundle's, and a document in the bundle passed toLoadthat defines one of their names is a compile error. - Every document in the source is checked, so a broken one fails the load even when no policy uses it.
- A nil source, or one that holds no policy or module, fails the load, since it would protect nothing. That catches an
fs.Subof the wrong directory, a source with only a README, and files under a directory whose name starts with., which the loader skips:the trusted source passed to Trusted holds no policies or modules. - Repeat it to add several sources. A source that
TrustedorFromalready names is read once. - A document in the bundle passed to
Loadthat's a byte-for-byte copy of a trusted one is left out, so the trusted source may be a directory of the bundle's ownfs.FS. A copy that differs is a compile error. - A
RequirewithoutFromwhose policy a trusted source defines takes it from there, asFromwould. - The rules for what resolves where are in Trusted sources.
sigil check --trustedand the configuration file'strustedkey make the same check.
//go:embed vocabulary
var vocabularyFS embed.FS
p, err := Deploy.Load(policy.MapFS(cm.Data), "payments.production",
policy.Trusted(vocabularyFS))A repository that holds the platform's directory and the teams' can pass the whole repository as the bundle and the platform's directory as the trusted source:
platform, err := fs.Sub(repo, "platform")
// ...
p, err := Deploy.Load(repo, "payments.production", policy.Trusted(platform))The CLI reads a file under both a path and a --trusted path as trusted only, so the same layout works there; a copy of a trusted document at another path is an error in the CLI.
Evaluating
func (p *Policy[In]) Eval(ctx context.Context, input In) (*Result, error)| Parameter | Is |
|---|---|
ctx | Cancels the evaluation |
input | One value of the kind's input struct |
- Returns the result with its trace, and an error when the evaluation failed.
- Never returns a nil result. With an error, the result holds the kind's default decision, its conflict outcome after a conflict when it declares one, or an empty outcome for a collecting kind; see Failed evaluations.
- Safe to call from any goroutine. A compiled policy is immutable and safe for concurrent use: concurrent evaluations share no mutable state and take no locks.
- Replacing a compiled policy at run time is a pointer swap, for example through a
sync/atomic.Pointer. An evaluation in flight keeps the policy it started with. To reload policies, see Reload without an outage. - Checks
ctxwhile it runs, and returns its error once it's done; when it checks and what the result then holds are in Context checks. - Host functions run on the calling goroutine, must be pure, must terminate and must not panic.
Evalstops as soon as a host function returns after the context is done. - A panic in a host function propagates out of
Evalunless the kind setsWithRecoverHostPanics.
res, err := p.Eval(ctx, input)
if err != nil {
// res still holds the kind's default decision, or its conflict outcome
}To act on each error, see Handle failed evaluations.
Planned
A Resolver that supplies input values by path instead of a Go struct is a proposal, not an exported API; see Dynamic input.
Errors
| Error | When | Fields |
|---|---|---|
*RuntimeError | An index out of range, integer overflow, an input or host function value outside its enum, or a host function that returned an error, or panicked under WithRecoverHostPanics | Err, Message, Help, Policy, Position |
*ConflictError | A conflict: two members of an exclusive set fired, or a collect one kind has several candidates at its top rank | Message, Policy, Candidates |
*AssertionError | An assert failed | Failures, Phase |
| The context's error | ctx was done before or during the evaluation | Unwrapped: errors.Is(err, context.DeadlineExceeded) holds for a deadline |
Tell them apart with errors.As.
RuntimeError
type RuntimeError struct {
Err error // the host function's error or *HostPanicError; nil for an error in the policy itself
Message string // what failed, such as "index 3 out of range for a list of 2"
Help string // what to do about it, when the evaluator knows; empty otherwise
Policy string // the policy being evaluated, the root
Position Position // the expression that failed
}
func (e *RuntimeError) Error() string
func (e *RuntimeError) Unwrap() errorError()returns the position followed by the message.Unwrap()returnsErr, soerrors.Isanderrors.Asfind the host function's own error through the policy.
HostPanicError
type HostPanicError struct {
Value any // what the function passed to panic
Func string // the host function's name in the kind
Stack []byte // the panicking goroutine's stack, as runtime/debug.Stack formats it
}
func (e *HostPanicError) Error() string
func (e *HostPanicError) Unwrap() error- It's the
Errof a*RuntimeErrorwhen a host function panicked and the kind setsWithRecoverHostPanics. - The
*RuntimeError's message names the function and the panic value. The stack stays out of it and is only inStack. Error()names the function and the panic value, without the stack.Unwrap()returns the panic value when it's an error, such as theruntime.Errorof a nil dereference.
ConflictError
type ConflictError struct {
Message string // what conflicts
Policy string // the policy being evaluated
Candidates []Candidate // only those that conflict
}
func (e *ConflictError) Error() stringCandidatesholds only the candidates that conflict: the top-rank tie, or the members of the exclusive set that fired. The result's trace has every candidate.Error()returns the message followed by one line per candidate, asCandidate.String()renders it, so it reads like the trace: which rules, at which positions, claimed what.- The result that comes with it holds the kind's
conflictoutcome when the kind declares one, and its default otherwise; see Failed evaluations.
AssertionError
type AssertionError struct {
Failures []AssertFailure
Phase AssertPhase // InputAsserts or OutcomeAsserts
}
func (e *AssertionError) Error() string
type AssertFailure struct {
Cause *RuntimeError // the runtime error that kept the assert from being checked; nil when its condition was false
Reason string // the assert's reason, its first argument
Policy string // the policy the assert is in
CallChain []Position // the invocations it was reached through; empty for an assert in the evaluated policy itself
Outcome []Candidate // for an outcome assert, the candidates that formed the outcome it read
Position Position // of the assert in its policy
}
func (f AssertFailure) Location() string
type AssertPhase int
const (
InputAsserts AssertPhase = iota + 1
OutcomeAsserts
)
func (p AssertPhase) String() stringFailureslists every assert that failed in the phase that stopped evaluation, sorted by position, including asserts whose own condition raised a runtime error.Phasesays which phase that was.InputAssertsrejects the caller's input;OutcomeAssertsmeans the policy produced an outcome it forbids, a defect in the policy.- Read the phase from
Phase, not from the trace: the trace is empty after a failed input assert, and also after a failed outcome assert when no rule fired. - What the result that comes with the error holds is in Failed evaluations.
Error()returns each failing assert's reason and location, with its runtime error when it has one.AssertFailure.Location()renders the call chain and position, asCandidate.Location()does.AssertPhase.String()returnsinputoroutcome, for a metric label, ornonefor the zero value, which noAssertionErrorfromEvalhas.
Result
type Result struct {
Decision string // "review"
Reason string // "service_owner"
Policy string // "payments.production": the policy the host evaluated
Payload map[string]any // untyped view; Value returns the typed one
Outcome []Entry // the winner, or for a collecting kind every candidate, sorted; may be empty
Trace Trace // all candidates, with conditions for the winning decision
// unexported fields
}
type Entry struct {
Decision string
Reason string
Policy string // the policy whose rule produced it; empty for the kind's default and conflict outcome
Payload map[string]any // defaults filled in
Position Position // of the constructor; unknown for the default and the conflict outcome
// unexported fields
}
type Trace struct {
Candidates []Candidate // sorted by precedence (or declaration order) and then position
}
type Candidate struct {
Decision string
Reason string
Policy string
Payload map[string]any
Position Position // of the constructor in its policy
CallChain []Position // the invocations it was reached through, outermost first
Conditions []Condition // the `when` conditions that held; only for candidates of the winning decision
}
func (c Candidate) Location() string
func (c Candidate) String() string
type Condition struct {
Text string // the condition as written, on one line
Position Position
}| Field | collect one | collect all |
|---|---|---|
Decision, Reason, Payload | The winner, or the kind's default when nothing fired | Empty |
Outcome | That one entry | Every folded candidate; with precedence, every folded candidate at the top rank. Sorted by declaration order, then position. May be empty |
Policy | The policy the host evaluated | The same |
Trace | Every candidate | Every candidate |
- Equal candidates, with the same decision, reason and payload, fold into one outcome entry.
Entry.PolicyandCandidate.Policyname the policy whose rule produced it. When the host evaluatespayments.productionand theservice_ownerreview fromdeploy.productionwins,Result.Policyispayments.production, and the entry and the candidate namedeploy.production.CallChainlists the invocations a candidate was reached through, each with file, line, column and document name. It's empty for a rule in the evaluated policy itself.Conditionsrecords whichwhenconditions held, outermost first, including the conditions around invocations, for every candidate of the winning decision.Candidate.Location()renders the whole chain and position:payments/production.sigil:10:3 → deploy/production.sigil:16:5.Candidate.String()renders the candidate on one line, the way a trace prints it: the decision, the reason, the location, and the payload with its fields sorted by name.Payloadmaps field names to untyped values, for logging and generic tooling. Typed matching returns the payload struct.
How the winner is picked: Evaluation semantics. Whether a collecting kind should get its own result type is open.
Typed matching
func (r *Result) Value() any
func (r *Result) Why() Outcome
func (e Entry) Value() any
func (e Entry) Why() Outcome
func (d Decision[T]) Match(res *Result) (T, bool)
func (d Decision[T]) MatchAll(res *Result) []Matched[T]
func (o Outcome) Is(res *Result) bool
type Matched[T any] struct {
Payload T // the payload struct, defaults filled in
Reason string // the reason the policy gave
Policy string // the policy whose rule produced it; empty for the kind's default and conflict outcome
Position Position // of the constructor; unknown for the kind's default and conflict outcome
}| Kind | Value, Why | Match | Is | MatchAll |
|---|---|---|---|---|
collect one | The winner's payload struct and reason handle | true and the payload when the outcome is that decision | true when the outcome is that decision with that reason | The winner when it's that decision, and nothing otherwise |
collect all with precedence | The one entry at the top rank; nil and the zero Outcome when the top rank holds none or more than one | true when the outcome is exactly one entry of that decision; false when the top rank holds more than one entry | As Match, comparing the reason too | Every entry of that decision |
collect all without precedence | Panics | Panics | Panics | Every entry of that decision |
Valuereturns the payload as theTof its decision'sDecision[T]. Payload types are unique within a kind, so a type switch on it has one case per decision.Whyreturns the reason as theOutcomehandleDecision[T].Reasonreturns, comparable with==and usable as aswitchcase.Entry.ValueandEntry.Whydo the same for one outcome entry, of any kind.Valuereturns nil for a nil result andWhythe zeroOutcome, which equals no handle.Matchreturns the zeroTandfalsefor a nil result or another decision;Isreturnsfalse;MatchAllreturns nil for a nil result.MatchAllreturns entries in outcome order, each aMatched[T]whose fields other thanPayloadare those of theEntryit came from.- The result that comes with an error holds the kind's default, or its conflict outcome after a conflict, so reading that decision or reason on it succeeds. Check
errbefore reading the result. - Comparing
res.Reasonwith a string compiles with a typo in it and never matches;WhyandIscompare through a handle that was checked when it was declared.
switch d := res.Value().(type) {
case ReviewData:
requestReview(d.Approvers, res.Reason) // d is a typed ReviewData
case ApproveData:
startRollout(d.Bake)
}
switch res.Why() {
case SoakTooShort:
retryAfterSoak(res)
case NoRuleMatched:
flagUncovered(p.Name()) // the default: no rule covers this deploy
}
if r, ok := Review.Match(res); ok {
requestReview(r.Approvers, res.Reason) // r is a typed ReviewData
}
for _, e := range res.Outcome { // a collecting kind
if d, ok := e.Value().(AdminData); ok {
grantAdmin(d.TTL, e.Reason)
}
}
for _, g := range Admin.MatchAll(res) {
grantAdmin(g.Payload.TTL, g.Reason) // g.Payload is a typed AdminData
}Positions
type Position struct {
File string // the path in the fs.FS given to Load; empty for a source given to Compile
Document string // the name in the header of the document at this place, such as "payments.production"
Line int
Column int
}
func (p Position) IsValid() bool
func (p Position) String() string- Every compile error and every trace entry carries a
Position. LineandColumnare 1-based, andColumncounts characters, not bytes.- A position with line 0 is unknown, as for the kind's default decision and conflict outcome, which have no source.
IsValid()reports false for it.
| Position | String() |
|---|---|
| File path doesn't say the document | policies.sigil:42:5 (payments.production) |
| File path says the document | deploy/production.sigil:16:5 |
No file, from Compile | 4:3 (payments.production) |
| Unknown | - |
The Go values always carry both the file and the document name, so a trace stays readable when many documents share one file, such as one ConfigMap key. The CLI prints positions the same way.
Package policytest
Import path github.com/spechtlabs/sigil/pkg/policytest. It runs the test files sigil test runs, from go test, with the host's Go types and real function implementations, unless a test file stubs them.
func Run[In any](t *testing.T, k *policy.Kind[In], fsys fs.FS, opts ...policy.LoadOption)
func Schema[In any](t testing.TB, k *policy.Kind[In], file string)| Function | Does |
|---|---|
Run(t, k, fsys, opts...) | Runs every *_test.yaml file in every directory of fsys against the policies in fsys. Each file's policy: is loaded with k.Load and opts, the same options the host passes to Load |
Schema(t, k, file) | Fails the test when the kind file at file, on disk, isn't k.Schema(). The failure shows both versions |
Runskips entries whose names start with., asLoaddoes.- Each test file becomes a subtest named after its path, and each case a subtest of it, so
go test -run 'TestPolicies/access/main_test.yaml/admins'selects them. - A test file that can't be read, doesn't parse, or whose policy doesn't compile fails its subtest. A case that doesn't get what it expects fails its own.
Runfailstat once whenkis nil orfsysholds no test files.- A test file's stubs replace the kind's host functions for its cases. The file's policy loads once with the file's stubs, and again for each case with stubs of its own, with the same
opts. - A test file can't expect a conflict; see Test a conflict.
func TestPolicies(t *testing.T) {
policytest.Run(t, Deploy, os.DirFS("../policies"), policy.Require("deploy.guardrails"))
}
func TestKindFileIsCurrent(t *testing.T) {
policytest.Schema(t, Deploy, "../policies/deploy_approval.sigil")
}To set it up in a host, see Run them from go test.
Package cli
Import path github.com/spechtlabs/sigil/pkg/cli. It builds the whole sigil command line into a host's own binary.
func Main(opts ...Option)
type Option = command.Option
func WithKind[In any](k *policy.Kind[In]) Option
func WithVersion(version string) Option| Symbol | Does |
|---|---|
Main(opts...) | Runs the sigil command line on os.Args and exits: status 1 when the command failed, after printing why, and 0 otherwise. An interrupt or SIGTERM cancels the running command. It doesn't return |
WithKind(k) | Links a kind into the binary. Repeat it for a host with several kinds. A nil k is ignored |
WithVersion(v) | Sets the version sigil version reports. When it's empty, the main module's version from the Go build info is reported |
What a linked kind changes in each command is in Host functions and host binaries. To build one, see Build a host binary.
func main() {
cli.Main(cli.WithKind(deploygate.Deploy))
}Package index
Every exported identifier of package policy:
| Identifier | What it is |
|---|---|
NewKind[In](name, opts...) *Kind[In] | Builds a kind from the input struct In; panics on an invalid kind |
Kind[In].Name, .Schema, .Load, .Compile, .Contract | The kind's name, its kind file, loading and compiling policies, and the tooling hook |
Option, WithVersion, WithAccepts, WithEnum, WithDecisions, WithCollect, WithPrecedence, WithReasonPrecedence, WithExclusive, WithDefault, WithConflict, WithFunc, WithRecoverHostPanics | Options for NewKind |
NewDecision[T](name, reasons...) Decision[T] | Declares a decision with payload struct T |
Decision[T].Name, .Reasons, .Reason, .Match, .MatchAll | The decision's name and reasons, one reason as an Outcome handle (panics on an undeclared one), and typed matching |
Outcome.Decision, .Name, .Is | A reason handle's decision and reason names, and whether a result is exactly that reason |
DecisionRef, OutcomeRef, Outcome | Any Decision[T]; a decision or one of its reasons; one reason |
None | The payload of a decision that carries only a reason |
Matched[T] | One entry MatchAll returns |
Result.Value, .Why, Entry.Value, .Why | The winner's or an entry's payload struct, for a type switch, and reason handle, for a switch on reasons |
LoadOption, Params, Require, RequireOption, From, Trusted | Options for Load and Compile |
MapFS(files) fs.FS | A map of file names to contents as an fs.FS |
Policy[In].Eval, .Name | Evaluating a compiled policy, and its name |
Result, Entry, Trace, Candidate, Condition | What an evaluation produced and why |
Position | A place in a bundle, with IsValid and String |
CompileError, Diagnostic | A failed compile and its diagnostics: Message, Help, Position, End |
RuntimeError, ConflictError, AssertionError, AssertFailure, HostPanicError | A failed evaluation, and the cause of a runtime error a recovered host panic became |
AssertPhase, InputAsserts, OutcomeAsserts | Which asserts an AssertionError reports |
Package policytest exports Run and Schema; package cli exports Main, Option, WithKind and WithVersion.
