Evolve a kind safely
About 2554 wordsAbout 9 min
2026-09-24
Policies across other teams compile against your exported kind file, so changing a Go struct changes a public contract. With this guide you tell which kind changes are safe, catch the unsafe ones in CI, and ship a breaking change without turning any policy repository red.
Keep the exported kind in the policy repo
Commit the exported kind file, deploy_approval.sigil, where the policies live, and regenerate it from Go with sigil export --out in the host binary, as Export the kind shows, so every kind change reaches review as a diff to that file in the same pull request as the Go change.
Bump the version, raise accepts when it breaks
Every policy and module pins the kind version it was written against, policy payments.production: DeployApproval@3, and the kind declares two numbers:
policy.NewKind[Input]("DeployApproval",
policy.WithVersion(4),
policy.WithAccepts(3),
...
)kind DeployApproval version 4, accepts: 3The rule for changing them is short. Bump version with every change to the kind, compatible or not. When the change is breaking, also raise accepts to the new version. You never keep old kinds around: every policy compiles against the one kind you have, and accepts only decides which pins you still load. A policy pinned below accepts fails with a message that tells its team to review the change and move the pin, instead of loading against a contract it wasn't written for:
payments/production.sigil:1:44: error: DeployApproval@1 is no longer accepted; the kind accepts version 3 and later
|
1 | policy payments.production: DeployApproval@1
| ^
= help: review the document against the kind's changes since version 1, then raise the pinA pin above version fails too, because the document was written for a kind this host doesn't have yet.
sigil breaking checks both numbers in CI against the kind file on the main branch.
Know what's compatible
A change is compatible when every policy that compiled before still compiles and still means the same thing. Look your change up in the compatibility table in Kind files, and make the one decision this page turns on: is it breaking? If it is, raise accepts along with version, and ship it in steps as below. If it isn't, bump version only.
Additions are compatible, new names included: a policy pinned below the version that added a name keeps its own, and the shadowed-kind-name lint tells its team to rename and move the pin (how the pin makes that safe). Removals and renames are always breaking, and the type checker reports them in the policy repo's CI.
Add a payload field
Say approvals should be able to notify the service's owners when the rollout starts. Add the field to the payload struct with a default:
type ApproveData struct {
Bake time.Duration `policy:"bake,default=1h"`
Notify bool `policy:"notify,default=false"`
}The regenerated kind changes in one line:
decision approve {
reason: release_manager | payments_sre
bake: duration = 1h
+ notify: bool = false
}Every existing approve(reason: release_manager) still compiles and gets notify = false. Policies that want the new behavior opt in with approve(reason: release_manager, notify: true).
Leave the default off and every existing call site breaks:
deploy/production.sigil:11:5: error: decision approve needs field "notify"
|
11 | approve(reason: release_manager)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
= help: approve takes reason: release_manager | payments_sre, bake: duration = 1h, and notify: bool
payments/production.sigil:18:3: error: decision approve needs field "notify"
|
18 | approve(reason: payments_sre, bake: 15m)
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
= help: approve takes reason: release_manager | payments_sre, bake: duration = 1h, and notify: boolSometimes that's what you want, because every policy author should make a conscious choice. Then treat it as a breaking change and follow the steps below.
Add an enum value
Say the platform starts running batch jobs through the same gate, and Tier needs a fourth value. Add a constant and pass it to WithEnum:
const (
TierCritical Tier = "critical"
TierStandard Tier = "standard"
TierInternal Tier = "internal"
TierBatch Tier = "batch"
)
var Deploy = policy.NewKind[Input]("DeployApproval",
policy.WithVersion(2),
policy.WithEnum(TierCritical, TierStandard, TierInternal, TierBatch),
...
)An added value is compatible, so bump version and leave accepts alone. Export the kind, and the diff shows both changes:
-kind DeployApproval version 1
+kind DeployApproval version 2
-enum Tier: critical | standard | internal
+enum Tier: critical | standard | internal | batchEvery policy that compiled before still compiles, unless the third point applies. Before you ship, check:
- No existing rule names
batch, so a batch service matches none of them, and the kind's default decides for it. ForDeployApprovalthat'sdeny(reason: no_rule_matched), which is safe, but tell the teams:deploy.production'stiersdefaults to[standard, internal], and a team that wants batch services reviewed has to passtiers: [standard, internal, batch]. Add a test case for a batch service so the decision is pinned either way. - An enum value joins the kind's namespace, like an input or a decision. A document pinned to
@1that has its ownlet batchkeeps it, andsigil checkwarns withshadowed-kind-nameuntil its team renames theletand raises the pin to@2. A document already pinned to@2can't declarebatchat all. - If another enum of the kind already declares
batch, the addition is breaking: a policy that writesbatchwhere nothing fixes its type, such aslet t = batch, stops compiling, because the name is now ambiguous. Raiseacceptsalong withversion, and have those policies writeTier.batch.sigil breakingreports the value as ambiguous.
Removing or renaming a value is breaking: every service.tier == internal in a policy stops compiling. Raise accepts and follow Ship a breaking change. Turning an existing string field into an enum is breaking too, because it changes the field's type; Replace a string field with an enum walks through it. Why these rules hold: Enums and versions.
Watch for changes that compile but change results
Reordering precedence, changing default, and adding, removing or changing a conflict outcome pass the type checker and still change decisions. Swapping review and approve makes the tour's service-owner deploy skip review (the example), and a default changed from deny(reason: no_rule_matched) to a review sends every deploy no rule covered to a human's queue. A conflict outcome only touches evaluations that already fail with a conflict, but the host still acts on what they return: adding conflict deny(reason: conflicting_rules) turns their deny(reason: no_rule_matched) into deny(reason: conflicting_rules), which a host checking NoRuleMatched.Is no longer sees, and a conflict that constructs an approval would turn a defect in a policy into a grant. For any of these changes:
- Treat it as breaking and raise
accepts, so every policy written against the old behavior stops loading until its team has looked at the new one. - Keep test cases that pin decisions and reasons. They catch a reordered
precedenceor a changeddefaultwhere the type checker can't, because they pin decisions rather than types. A test case can't expect a conflict, so a changedconflictoutcome only shows in the kind file diff, insigil breaking, and in a Go test that checks the outcome of a conflict, as Test a conflict does.
Check for breaking changes in CI
Four checks run today, split between the two repositories:
- In the host repo,
policytest.Schemaingo test, orsigil export --check --out ../policies/deploy_approval.sigilfrom a host binary, fails when the Go kind changed and the kind file wasn't regenerated. Every contract change then reaches review as a diff todeploy_approval.sigil. - In either repo,
sigil breakingcompares the kind file with the one on the main branch, classifies every change by the compatibility table, and fails whenversiondidn't move or a breaking change didn't raiseaccepts. - In the policy repo,
sigil checkagainst the new kind file fails on every use of a removed or renamed name, on every payload that misses a new required field, and on every pin belowaccepts. sigil test, also in the policy repo, fails when a test case's decision changes, which is the only automated catch for what a reorderedprecedenceor a newdefaultdoes to a policy. It can't catch a changedconflictoutcome.
sigil breaking needs only the old and the new kind file, and reads the old one from git with - as its path. Say a change to version 3, accepts: 2 adds a region input, drops deny's no_release reason, swaps review and approve in precedence, and adds standard to an enum Plan while Tier declares it too, and bumps version to 4 only:
$ git show main:deploy_approval.sigil | sigil breaking - deploy_approval.sigil
deploy_approval.sigil: breaking: enum Plan declares `standard`, which Tier declares too
= help: a bare `standard` without context becomes ambiguous; raise `accepts` to 4, and qualify it as `Tier.standard`
deploy_approval.sigil: compatible: input region was added
deploy_approval.sigil: breaking: decision deny lost reason `no_release`
= help: policies that construct deny(reason: no_release) no longer compile; raise `accepts` to 4
deploy_approval.sigil: breaking: precedence changed
- deny > review > approve
+ deny > approve > review
= help: every policy still compiles, but the decisions rank differently; raise `accepts` to 4, so policies pinned to older versions are reviewed before they load
deploy_approval.sigil: error: 3 breaking changes, but `accepts` is 2
= help: raise `accepts` to 4, so policies written against version 3 or earlier are reviewed before they load
✗ DeployApproval 3 → 4: 3 breaking changes and 1 compatible changeEvery help: names the number to raise accepts to. With the header at kind DeployApproval version 4, accepts: 4, the same changes print as covered and the command exits 0. sigil breaking lists the classes, the header rules and the JSON record.
On GitHub Actions, run it on every pull request that changes the kind file:
name: kind
on:
pull_request:
paths:
- deploy_approval.sigil
jobs:
breaking:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
with:
go-version: stable
- run: go install github.com/spechtlabs/sigil/cmd/sigil@latest
- name: Compare the kind file with the base branch
run: |
git fetch --depth=1 origin "$GITHUB_BASE_REF"
git show FETCH_HEAD:deploy_approval.sigil | sigil breaking - deploy_approval.sigilIn the host repo, name the file sigil export --out writes. The first pull request that adds the kind file has nothing to compare with, so git show fails and so does the step; skip the job for it.
The commands for the other checks, with the rest of a policy repository's job, are in Check policies in CI.
Ship a breaking change
Removing or renaming something breaks every policy that uses it. Do it in steps so no policy repo is ever red:
- Add the new name alongside the old one, and bump
version. For a rename ofService.tiertoService.criticality, both fields exist for a while. This is a compatible change, soacceptsstays where it is. - Move the policies over to the new name.
sigil checktells you where the old one is still used, because every reference is a type-checked field access. - Remove the old name, bump
version, and raiseacceptsto it. Teams that finished step 2 move their pins forward in the same change. A policy that didn't fails withDeployApproval@3 is no longer accepted, which tells its team to review the kind's changes and raise the pin, next to the type errors about the missing field.
There's no deprecation marker in the kind format yet, so step 1 relies on communicating the migration out of band.
Changing a field's type follows the same pattern: add a field with the new type under a new name, migrate, then remove the old one.
Remember the other evaluators
Adding a host function doesn't break existing policies, but anything that evaluates a policy calling it needs its implementation. The stock sigil binary knows only the signature from the kind file, so sigil eval and sigil test return a runtime error when they reach the call; a host binary built with pkg/cli links the real function. Rebuild and ship those binaries before policies start using a new fn; until they ship, test files and sigil eval --stub can stub the new function. Tools that only type-check, such as sigil check, work from the exported signature right away.
Migrate to the new decision syntax
Sigil used to declare a decision's payload in parentheses and its reasons in a block, and constructors passed the reason first, without a label. Both are now written with reason::
// before
decision approve(bake: duration = 1h) {
release_manager
payments_sre
}
approve(payments_sre, bake: 15m)
default deny(no_rule_matched)
// after
decision approve {
reason: release_manager | payments_sre
bake: duration = 1h
}
approve(reason: payments_sre, bake: 15m)
default deny(reason: no_rule_matched)The old forms still parse, so nothing has to change by hand. They don't check any more: the kind loader rejects the old declaration and the type checker rejects a positional reason, and each error's help holds the rewritten form. Why every argument is named now: Why arguments are named.
In the host repo, upgrade Sigil and export the kind again with
sigil export --outfrom the host binary. Your Go code doesn't change:NewDecisionand its reason handles work as before, and the export writes the new syntax.In the policy repo, run
sigil checkagainst the new kind file. It reports every positional reason:$ sigil check deploy_approval.sigil deploy/ payments/ deploy/guardrails.sigil:8:8: error: the reason is a named argument | 8 | deny(not_eligible) | ^^^^^^^^^^^^ = help: write `deny(reason: not_eligible)` deploy/guardrails.sigil:12:8: error: the reason is a named argument | 12 | deny(soak_too_short) | ^^^^^^^^^^^^^^ = help: write `deny(reason: soak_too_short)` deploy/production.sigil:11:13: error: the reason is a named argument | 11 | approve(release_manager) | ^^^^^^^^^^^^^^^ = help: write `approve(reason: release_manager)` deploy/production.sigil:16:12: error: the reason is a named argument | 16 | review(service_owner, approvers: approvers) | ^^^^^^^^^^^^^ = help: write `review(reason: service_owner, approvers: approvers)` payments/production.sigil:18:11: error: the reason is a named argument | 18 | approve(payments_sre, bake: 15m) | ^^^^^^^^^^^^ = help: write `approve(reason: payments_sre, bake: 15m)` ✗ checked 4 files, 5 errorsRewrite every file at once.
sigil fmt --writeturns old decision declarations into the new syntax, in kind files you maintain by hand as well, and putsreason:in front of every positional reason:$ sigil fmt --write deploy/ payments/ ✓ reformatted 3 files, 1 left unchanged deploy/guardrails.sigil deploy/production.sigil payments/production.sigilRun
sigil checkandsigil testagain.fmtonly relabels, so the decisions your test cases pin don't change. Commit the rewrite on its own, apart from any rule change, so the diff is easy to review.
The new syntax doesn't change the contract, so the kind's version stays where it is.
