Build policies in Go
About 2305 wordsAbout 8 min
2026-10-01
By the end of this guide the platform's change-freeze vocabulary, deploy.freeze with its is_frozen, is written in Go next to the kind, rendered to a .sigil file you commit, checked for drift in CI and served as a trusted source, and the host fills in the freeze it reads. Every team then writes its rules against is_frozen in plain Sigil.
The code is from the example service, whose DeployApproval kind is at version 2 with a freeze input. Why the freeze is input and not part of the module: Facts, vocabulary and rules. Every function of pkg/build and the Sigil it renders is in the Go builder reference.
Add the fact to the input
The module can only read what the kind declares. Give the input a field for the fact, with a struct of its own:
type Input struct {
Release Release `policy:"release" json:"release"`
Service Service `policy:"service" json:"service"`
Actor Actor `policy:"actor" json:"actor"`
// Environment is where the release goes, such as production.
Environment string `policy:"environment" json:"environment"`
// Freeze is the change freeze in force when the policy runs. It is a
// fact the host resolves, not something a client asserts: deploygate
// fills it from its freeze source after decoding a request.
Freeze Freeze `policy:"freeze" json:"freeze"`
}
type Freeze struct {
// Environments are the environments frozen right now. deploygate sends
// an empty list rather than null, so a logged input replays as it was.
Environments []string `policy:"environments" json:"environments"`
// Unknown is true when the host can't tell which environments are
// frozen, because its freeze source has been unreachable for longer than
// it may be stale. The policy then treats every environment as frozen:
// it fails closed.
Unknown bool `policy:"unknown" json:"unknown"`
}Add Unknown from the start. A host that can't reach its flag service has to say so, and the module decides what that means.
A new input is a change to the kind, so bump its version. The example added the reason change_freeze in the same step, ranked between not_eligible and soak_too_short. Both are additions, and the kind sets no accepts, so every version stays accepted: the team policies keep their @1 pins, and only the documents that read the freeze, deploy.freeze and the guardrails, pin @2; Evolve a kind safely has the rules. Re-export the kind file afterwards, as Export the kind shows.
Write the module in Go
Put the builder next to the kind, in a function that returns the document. Nothing gets registered at package level; whoever needs the module calls the function:
// FreezeModule builds deploy.freeze, the platform's vocabulary for the
// change freeze, in Go: is_frozen is true when the input's freeze names the
// environment or is unknown.
func FreezeModule() *build.ModuleDoc[Input] {
return build.Module("deploy.freeze", Kind, func(m *build.ModuleDoc[Input], in *Input) {
m.Comment("freeze is data the host fills in on every request, never part of this\n" +
"module. An unknown freeze counts as frozen, so the policy fails closed.")
build.Pub(m, "is_frozen", build.Or(
build.Field(&in.Freeze.Unknown),
build.In(build.Field(&in.Environment), build.Field(&in.Freeze.Environments)),
))
})
}inis a shadow of the input.build.Field(&in.Freeze.Unknown)reads the field by its address, and the path,freeze.unknown, comes from thepolicytags when the module renders.build.Pubdeclares apub let, the name other documents import.build.Orandbuild.Inare the operators, and the printer adds parentheses only where the precedence needs them.- Read the facts, never their values: no
build.Litof what the flag says right now.
The function renders to:
// Code generated by pkg/build from vocabulary.go. DO NOT EDIT.
module deploy.freeze: DeployApproval@2
// freeze is data the host fills in on every request, never part of this
// module. An unknown freeze counts as frozen, so the policy fails closed.
pub let is_frozen = freeze.unknown or environment in freeze.environmentsThe header names the Go file, so a reviewer who wants to change the module knows where to do it. build.WithHeader replaces the text.
The handwritten guardrails import the name and deny with it:
use deploy.freeze.{is_frozen}
when is_frozen {
deny(reason: change_freeze)
}Write a policy in Go
A policy works the same way, with params, rules and decisions. This one, from the package's examples and written against a smaller version-1 kind, reads is_frozen from a module written by hand, so it names it with build.Extern, and build.Ref generates the import:
freeze := build.Extern("deploy.freeze") // written by hand, or built in Go
guardrails := build.Policy("deploy.guardrails", kind, func(p *build.PolicyDoc[Input], in *Input) {
minSoak := build.Param(p, "min_soak", build.Default(24*time.Hour), build.Min(time.Hour), build.Max(48*time.Hour))
p.When(build.Ref[bool](freeze, "is_frozen"), func(b *build.Block) {
b.Decide(deny.Reason("change_freeze"))
})
p.When(build.And(
build.Field(&in.Release.Soak).Lt(minSoak),
build.Not(build.Field(&in.Release.Hotfix)),
), func(b *build.Block) {
b.Decide(deny.Reason("soak_too_short"))
})
}, build.WithHeader(""))policy deploy.guardrails: DeployApproval@1
use deploy.freeze.{is_frozen}
param min_soak: duration = 24h, min: 1h, max: 48h
when is_frozen {
deny(reason: change_freeze)
}
when release.soak < min_soak and not release.hotfix {
deny(reason: soak_too_short)
}deny.Reason("change_freeze") is the kind's reason handle, so a misspelled reason panics where the handle is taken. The example service keeps its guardrails handwritten and builds only the vocabulary, which is the usual split: platform engineers own the module, and the rules stay where every team can read and change them.
Render it and commit the file
Write the file from a test with an -update flag, so one command regenerates it and the same test checks it:
// platformDir holds the platform's trusted documents, where the rendered
// vocabulary lives at its default path, deploy/freeze.sigil.
const platformDir = "../../policies/platform"
var update = flag.Bool("update", false, "rewrite the rendered vocabulary under policies/platform")
func TestVocabularyIsCurrent(t *testing.T) {
doc := deploy.FreezeModule()
if *update {
if err := build.Write(platformDir, doc); err != nil {
t.Fatalf("writing %s: %v", doc.Path(), err)
}
}
if err := build.Diff(os.DirFS(platformDir), doc); err != nil {
t.Fatalf("%v\nregenerate it with: go test ./internal/deploy -run Vocabulary -update", err)
}
}build.Write puts the module at its Path(), the name with dots as slashes, deploy/freeze.sigil, under the directory. Regenerate and commit:
go test ./internal/deploy -run Vocabulary -update
git add policies/platform/deploy/freeze.sigilThe example's mise run generate does this together with exporting the kind file. Review the rendered file in the pull request like any other policy; the Go diff shows how it was built, the Sigil diff shows what changed.
Catch drift in CI
Without -update, the test is the drift check. Change the Go code and forget to regenerate, here to fail closed on an unknown freeze only in production, and go test fails with the diff:
--- FAIL: TestVocabularyIsCurrent (0.00s)
vocabulary_test.go:31: build: 1 rendered file(s) out of date; regenerate them from the Go code
deploy/freeze.sigil is stale:
--- deploy/freeze.sigil (on disk)
+++ deploy/freeze.sigil (rendered)
@@ -4,4 +4,5 @@
// freeze is data the host fills in on every request, never part of this
// module. An unknown freeze counts as frozen, so the policy fails closed.
-pub let is_frozen = freeze.unknown or environment in freeze.environments
+pub let is_frozen =
+ freeze.unknown and environment == "production" or environment in freeze.environments
regenerate it with: go test ./internal/deploy -run Vocabulary -update
FAILA module that renders can still fail to type-check, or break the documents that import it. Check it against the kind, in one bundle with the handwritten documents next to it:
func TestVocabularyChecks(t *testing.T) {
platformDeploy := policies.Only(os.DirFS(platformDir), "deploy")
if err := build.Check(deploy.Kind, platformDeploy, deploy.FreezeModule()); err != nil {
t.Fatal(err)
}
}policies.Only shows only the deploy/ directory of the platform tree. Passing the whole tree fails, because access/ holds AccessGrant documents and a bundle holds one kind: document is for kind AccessGrant, not DeployApproval. The rendered module replaces the committed file at the same path, so the check sees what the Go code builds now. Rename is_frozen to frozen in Go, regenerate, and the handwritten guardrails stop compiling:
--- FAIL: TestVocabularyChecks (0.00s)
vocabulary_test.go:42: deploy/guardrails.sigil:4:20: error: deploy.freeze has no pub let `is_frozen`
|
4 | use deploy.freeze.{is_frozen}
| ^^^^^^^^^
= help: did you mean `frozen`? deploy.freeze exports: frozen
deploy/guardrails.sigil:12:6: error: unknown name `is_frozen`
|
12 | when is_frozen {
| ^^^^^^^^^
= help: names come from the kind's inputs, host functions, decisions and enum values, and the document's params, lets and imports
FAILA diagnostic inside the rendered module also names the Go call that built the statement, in a = go: line; see CheckError. Policies in other repositories that import the old name break the same way when they next check, which is why a rename belongs in a new name next to the old one; see Vocabulary is a public API.
Both tests run with go test ./..., so the CI job that tests the host already runs them.
Serve it as a trusted source
Ship the module from a source teams can't write to, so no team can define its own deploy.freeze with pub let is_frozen = false. Embed the directory that holds the committed file:
//go:embed platform
var platformFiles embed.FSThen pick the load option by whether a required policy imports the module:
A required policy imports it. The example's
deploy.guardrailsusesis_frozen, sopolicy.Fromalready reads the module from the platform's documents, along with everything else the guardrails use:p, err := deploy.Kind.Load(teamFS, "payments.production", policy.Require("deploy.guardrails", policy.From(policies.PlatformDeploy)))Only team policies import it. Pass the source with
policy.Trusted, which reserves its names without requiring a policy:p, err := deploy.Kind.Load(teamFS, "payments.production", policy.Trusted(vocabularyFS))
Either way, a team document that defines deploy.freeze differently fails the load. A byte-for-byte copy of the trusted file is the same definition and is left out, so the bundle may be a repository that holds the platform's directory too. A trusted source that holds no policy or module fails the load, which catches an embed or fs.Sub of the wrong directory. build.FS(deploy.FreezeModule()) returns an fs.FS both options take, but loading the embedded file keeps the service on the bytes that were reviewed, and the drift test keeps those equal to the Go code.
Policy repositories check against the same source. In sigil.yaml, list the directory under trusted, or under the trusted of the require entry that imports it, as the example does; the keys are in Configuration file. A team file that redefines the module then fails sigil check:
policies/teams/payments/freeze.sigil:1:8 (deploy.freeze): error: module deploy.freeze is defined twice
|
1 | module deploy.freeze: DeployApproval@2
| ^^^^^^^^^^^^^
= help: the name belongs to the trusted source, defined at policies/platform/deploy/freeze.sigil:3:1; documents resolve by name, so each name has one definitionThe rules are in Trusted sources.
Fill the facts in the host
The host resolves the freeze and writes it into every input, after decoding the request, so a client can't send its own:
roles := deployRoles(st.grants)
in := req.DeployInput(roles)
// The freeze is the host's fact, not the client's: whatever was decoded,
// the policy reads the freeze source's answer, and the response, the span
// and the log carry it, so the input the policy read can be replayed.
in.Freeze = s.freeze.Freeze()The source answers from memory, since it runs once per request:
// Source tells which environments are frozen. Freeze is called once per
// deploy request, so it answers from memory and never waits on the network.
type Source interface {
// Freeze returns the freeze in force now. Its Environments are never
// nil, and the caller may keep or change them.
Freeze() deploy.Freeze
}- Refresh the fact in the background, and answer every request from the last result. The example's OFREP source evaluates a feature flag every 15 seconds.
- Treat a value you can't read as a failed refresh, never as "nothing frozen". deploygate checks every environment a flag names against
--freeze-known-environments, so"Production"or"prod"fails the refresh and the last answer stays in force. - Set
Unknownonce the last answer is older than you can tolerate, and from startup until the first answer arrives. The module turns that into a deny. - Log the whole input you evaluated, the fact included, as one field. deploygate's
deploy decisionlog line carries it asinput, andsigilc evalon that field alone reproduces the decision; see the README's Replay a decision.
The example's README covers its two sources, the flags that configure them, which flag values fail a refresh, and a frozen deploy's full response.
Test the rules on the facts
Write policy test cases with the fact in the input, one for each state the host can report: frozen, unknown and frozen elsewhere.
- name: a deploy to a frozen environment is denied
input_file: testdata/frozen.json
expect:
decision: deny
reason: change_freeze
- name: a deploy while the freeze is unknown is denied, failing closed
input_file: testdata/freeze-unknown.json
expect:
decision: deny
reason: change_freeze
- name: a freeze of another environment doesn't stop a production deploy
input_file: testdata/frozen-elsewhere.json
expect:
decision: review
reason: service_owner
payload:
approvers: [payments-leads, security-leads]Test your policies runs them with sigil test and from go test.
Next steps
- Go builder: every function, the Sigil it renders and its errors.
- Facts, vocabulary and rules: why the layers are split this way, and when a host function fits better.
- Policies in a ConfigMap: ship the team policies separately from the trusted platform documents.
