Policy files
About 2895 wordsAbout 10 min
2026-09-24
The statements of policy and module documents in .sigil files.
A complete team policy is in Per-team policies.
Statements at a glance
| Statement | Where | Meaning |
|---|---|---|
policy <name>: <Kind>@<N> | first statement | Names the policy, the kind it implements and the kind version it was written against |
use <path> | after the header | Imports names from another policy or module. Never adds rules by itself |
param <name>: <type> [= <expr>] [, min: <expr>] [, max: <expr>] | top level | Typed input set at instantiation. No default means required; bounds limit what a caller may bind |
[pub] let <name> = <expr> | top level or nested | Named, reusable expression. pub lets other documents import it; only at the top level |
when <expr> { ... } | top level or nested | Rule block. Body holds nested when blocks, decisions, asserts and invocations |
assert("<reason>", <expr>) | top level or nested | Condition that must hold, or evaluation fails with an assertion error |
<policy>(<param>: <expr>, ...) | top level or nested | Invokes an imported policy, adding its rules with its params bound |
After the imports, statements come in any order. Modules allow only use and let. A policy or module can't declare an enum or a type; they come from the kind. Kind files use a different set of statements; see Kind files.
policy
policy deploy.production: DeployApproval@1- The header is the first statement of a policy document. Each policy document has exactly one.
- The name is a dotted policy name.
- The part after the colon names the kind the policy is checked against. Every input, host function, type and decision the policy can refer to comes from that kind.
- Referring to a kind the host doesn't know is a compile error.
@1pins the kind version the policy was written against. The pin is required; a missing pin is an error that suggests the kind's current version. Which pins the host accepts: Versioning. How a name the kind added later is treated: Identifiers.
Why: Kinds as contracts.
use
use deploy.production // binds `production`
use deploy.production as approvals // binds `approvals`
use deploy.common // qualified: common.cleared
use deploy.common.{cleared, owns_service} // selective
use deploy.common.{owns_service as owner} // selective with aliasuse brings names from another document into scope. It never adds rules by itself: an imported policy contributes nothing until it's invoked.
| Form | Binds |
|---|---|
use a.b of a module | Qualifier b: b.cleared reads the module's cleared |
use a.b of a policy | Name b, to invoke and to read its pub lets as b.<let> |
use a.b as c | The same, under the name c |
use a.b.{x, y} | Each listed pub let under its own name |
use a.b.{x as z} | The listed pub let under the name after as |
- The last path segment is the bound name unless
asrenames it. - A path names a document in the bundle:
use deploy.commonfinds the document whose header ismodule deploy.common, in whichever file it lives. See Name resolution. - The imported document must implement the same kind. Importing a module or policy written for another kind, or a kind document, is a compile error.
- Imports come right after the header, before any
param,let, rule or invocation. - An imported name that collides with an input, host function, param,
letor another import is a compile error. Nothing shadows silently. - There are no wildcard imports. Every name used in a document is either defined there or listed in a
use. - The import graph must be acyclic.
- An unused import is the
unused-importlint warning, not an error.
payments/production.sigil:3:31: error: `release` is already the name of an input
|
3 | use deploy.common.{cleared as release}
| ^^^^^^^
= help: every name in a document means one thing; rename one of themImporting from a policy
- A policy's
pub lets are imported the same way as a module's: selectively,use deploy.guardrails.{is_hotfix}, or through a whole import,guardrails.is_hotfix. - A whole import of a policy also binds the name you invoke it by, so one
useserves both. - A
pub letin a policy can't depend on a param, directly or through otherlets. The compiler reports it where theletis declared, not where someone imports it.
deploy/guardrails.sigil:5:9: error: let `soak_ok` can't be `pub`: it reads param `min_soak`
|
5 | pub let soak_ok = release.soak >= min_soak or release.hotfix
| ^^^^^^^
= help: a param has no value outside an invocation; move the let to a module, or drop `pub`param
param approvers: list<string>
param tiers: list<Tier> = [standard, internal]A param is a typed value supplied when the policy gets instantiated. It has a name, a type and optionally a default.
- A param without a default is required. Instantiating the policy without binding it is a compile error.
- A default must have the declared type and must be a constant expression: literals, list and map literals of literals, and arithmetic on those. It can't read inputs, lets or other params.
- The type can be any type from Types except optional types.
- A default of an enum type is a bare value, a list of them such as
[standard, internal], or a map keyed by them.
| Bound by | Checked |
|---|---|
| A policy that invokes this one | At compile time, at the argument |
The Go host, through policy.Params when it compiles or loads the policy; see Load options | By Load or Compile |
Bounds
param min_soak: duration = 24h, min: 1h, max: 48hmin and max limit the values a caller may bind: guardrails(min_soak: 0s) is a compile error.
- Bounds apply to
int,floatanddurationparams. - Each is optional, both are inclusive, and they may come in either order.
- A bound is a constant of the param's type, like a default.
mincan't be abovemax, and the default must lie between them.- An invocation argument is checked against the bounds when the policy compiles, at the argument that breaks it.
- A value bound from Go through
policy.Paramsis checked byLoadorCompile. - Nothing is left to evaluation time, so a bad value never fails an evaluation.
minandmaxare names in a named-argument position, not keywords, so a kind can still declarefn max(int, int) -> int.
teams/payments.sigil:5:22 (payments.production): error: min_soak: 0s is below the minimum 1h
|
5 | guardrails(min_soak: 0s)
| ^^
= help: deploy.guardrails declares `param min_soak: duration = 24h, min: 1h, max: 48h`For a required guardrail, policy.From loads the file that declares its bounds from a trusted source. Why: Composition without templating.
let
let owns_service = actor.teams any in service.owners
let cleared = split(service.labels["regions"], ",") all in actor.regionsA let binds a name to an expression.
- The compiler infers the type from the expression. There's no annotation.
- The expression can use inputs, params, host functions, other lets and imported lets (
cleared, orcommon.clearedthrough a whole-module import). - A top-level
letis visible in the whole document. Aletinside awhenbody is visible in that body and the blocks nested in it, and nowhere else, not even in thewhen's own condition. See Scoped lets. - A
letis private to its document unless it's declaredpub let. Only apub letcan be imported. See Exporting lets. - Lets must form a directed acyclic graph.
let a = btogether withlet b = ais a compile error, and so is any longer cycle. - A let is a name for an expression, not a mutable variable. It can't be reassigned, and binding the same name twice is a compile error.
- The order of
letstatements doesn't matter; a let may refer to one declared further down. The same holds inside awhenbody.
deploy/fresh.sigil:4:37: error: let `fresh` depends on itself
|
4 | let settled = release.hotfix or not fresh
| ^^^^^
= help: lets form a directed acyclic graph; a let can't depend on itself, even through other letsScoped lets
when active {
let sre = any r in actor.roles: r like "sre-*"
when sre and release.hotfix { approve(reason: release_manager) }
when sre and not release.hotfix { review(reason: service_owner, approvers: approvers) }
}A let inside a body names a sub-expression that several nested blocks share, without making it visible to the rest of the document.
- It can read everything its body can: inputs, params, top-level lets, imports, and the lets of every enclosing body.
- It can't shadow anything. Its name can't be taken by an input, host function, decision, param, import or any other
letin the document, including aletin an unrelatedwhenbody. - It can't be
pub. - It's only evaluated when its body is reached, so it can rely on the enclosing conditions: in
when len(xs) > 0 { let first = xs[0] ... }, withlena host function the kind declares, the index can't fail. See Evaluation semantics.
Exporting lets
module deploy.common: DeployApproval@1
pub let cleared = split(service.labels["regions"], ",") all in actor.regions and not restricted
let restricted = service.labels has "restricted"pub letmakes a top-levelletimportable.- A
letwithoutpubis private: other lets and rules in the same document can use it, and ausethat names it is a compile error. - The rule is the same for modules and policies.
- A private
letthat nothing reads is theunused-letlint warning.
when
when cleared {
when service.tier == critical
and "release_manager" in actor.roles {
approve(reason: release_manager)
}
when service.tier in tiers
and owns_service {
review(reason: service_owner, approvers: approvers)
}
}A when block has a condition and a body in braces.
- The condition must have type
bool. Anything else is a compile error; there's no truthiness. - The body contains nested
whenblocks, scoped lets, decision constructors, asserts and policy invocations, and nothing else. There's noparam, nouseand no bare expression inside a body. - There's no
else. Writewhen not x { ... }instead. - A nested
whenfires only if every enclosing condition holds: nesting is a conjunction. See Evaluation semantics. - A body may contain more than one decision constructor. Each one that's reached becomes its own candidate.
assert
assert("negative_soak", release.soak >= 0s)
when service.tier == critical {
assert("critical_needs_team_label", service.labels has "team")
}
assert("sod_customer_dev", [customer_data_writer, development_environment_writer] exclusive in outcome)An assert states something that must be true whenever it's reached. If its condition is false, evaluation fails: Eval returns an assertion error, and the host records it as an error, not as a decision.
- The condition must have type
bool. It can read inputs, params, lets and imported lets, and, unlike any other expression,outcome, the decisions evaluation produced, with their payloads throughoutcome.<decision>. - The reason comes first, as in a decision constructor. It's a string literal, not a name the kind declares. Dynamic text isn't allowed.
assert(cond, "reason")is a parse error that says so.- An assert may appear at the top level or inside a
whenbody. Inside a body it's only checked when every enclosing condition holds. An assert in an invoked policy gets the invocation's enclosing conditions too. - An assert never produces a candidate and never changes the outcome. It can only fail the evaluation.
An assert that reads outcome is checked after the rules, every other assert before them; see Assertions. exclusive in over outcome keeps two decisions of a collecting kind from being granted together. The two phases, and what the host gets back when one fails, are on Evaluation semantics.
Why: Asserts and decisions. Open design questions: Assertions.
Policy invocation
use deploy.guardrails
use deploy.production
guardrails(min_soak: 4h)
when service.labels["compliance"] == "pci" {
production(approvers: ["payments-leads", "security-leads"])
}An imported policy is invoked like a decision constructor. A decision constructor produces one candidate; an invocation produces the invoked policy's whole candidate set, with its params bound to the arguments. Both can go at the top level or inside a when body.
- An invocation inside
whenblocks adds the enclosing conditions to every rule of the invoked policy. Theproduction(...)call above behaves exactly as ifdeploy.production's rules were pasted inside the block. See Evaluation semantics. - Arguments are named-only, like decision payloads, and a trailing comma is allowed. An enum value takes its type from the param:
production(tiers: [standard]). - Every param without a default must be bound, and each value must have the param's type. An unknown name, a type mismatch, or a required param left unbound is a compile error.
- Params with defaults can be left out. The parentheses are still required:
baseline()invokes a policy (hypothetical here) whose params all have defaults. - Arguments may reference constants and the invoking policy's own params, but not inputs or
lets, not even aletthat only holds a constant. A team can pass its own params through:production(approvers: approvers). - Only an imported policy of the same kind can be invoked. Invoking a module is a compile error.
- A policy can be invoked more than once with different arguments. Each call is a separate instantiation with its own params.
- Invoking a policy twice with identical arguments is legal; the
duplicate-invocationlint warns about it. - The invocation graph must be acyclic. A policy that invokes itself, directly or through others, is a compile error.
- A host can require that certain policies are invoked unconditionally, so a
whencan't switch their denies off. See Required policies.
Why: Composition without templating.
Modules
module deploy.common: DeployApproval@1
pub let owns_service = actor.teams any in service.owners
pub let cleared = split(service.labels["regions"], ",") all in actor.regions
pub let eligible = "deployer" in actor.roles
and environment == "production"
and service.labels has {
"app.kubernetes.io/managed-by": "argocd",
"platform.example.com/lifecycle": "ga",
}A module is a document of shared, typed matchers. It has no rules and no params, so importing from it can never change a decision by itself.
- The header names the kind and pins a kind version the same way a policy's does. The
lets read inputs and type-check against them. - A module may contain
useandletstatements only.param,when,assert, decision constructors and invocations are compile errors. - A module may
useother modules. - Only
pub lets are exported. A module's other lets are private helpers for its ownpub lets. - A host can't load or evaluate a module.
Loadon a module's name fails withdeploy.common is a module, not a policy.
deploy/common.sigil:3:1: error: a module can't contain `param`
|
3 | param min_soak: duration = 24h
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
= help: a module holds only imports and lets; rules, params and invocations belong in a policyIdentifiers
Each policy and module has one flat top-level namespace containing:
- the kind's inputs, host functions and decisions, its enums' names and every value of its enums,
- the document's own params and lets, including the lets inside
whenbodies, - every name bound by a
use.
| Case | Result |
|---|---|
| Two of these names collide | Compile error. Nothing shadows anything |
A param named release in a kind that declares input release | Compile error |
A quantifier or filter variable named approvers in a policy with a param of that name | Compile error |
A let called deny in a kind that declares decision deny | Compile error. Decision names are in the namespace, because a bare decision name is a value in assert conditions |
A let, param or quantifier variable named critical in a kind whose enum Tier declares critical | Compile error |
A let, param or quantifier variable named Tier in a kind that declares enum Tier | Compile error |
| An import whose bound name is a decision | Compile error; rename it with as |
| A reason with the same name as anything else | Allowed. Reasons aren't in the namespace: let release_manager = ... is fine next to approve(reason: release_manager), and so is an enum value named release_manager |
| A document pinned below the kind's current version collides with an input, host function, decision, enum or enum value | Allowed. The document's own name wins, the kind's name is out of reach in that document, and the shadowed-kind-name lint says so |
| The same collision in a document pinned to the current version | Compile error |
| Two of the document's own names collide, at any pin | Compile error |
Reasons only appear after a decision's name, as approve.release_manager, or after reason: in a constructor. Two enums may share a value name; which one a bare name means is on Enum values.
Why: Why the language looks like this and Adding a name never breaks a policy.
