Planned designs
About 2253 wordsAbout 8 min
2026-09-29
This page collects the designs that are decided or proposed but not implemented. The reference pages describe only what exists and point here with a one-line note, so each design below is written out in full in this one place. The roadmap tracks them, and Open questions lists what's still undecided around them.
Host-ordered types
Status: not implemented; tracked on the roadmap under Policies. Nothing blocks it, since kinds, the checker and the evaluator exist. Open question: String literals for host-ordered types.
Host-ordered types are the chosen direction for versions and other domain values with their own ordering. Today type X ordered doesn't parse, and policy.WithOrdered doesn't exist. Until they do, a host function compares the values; see Compare versions.
Some values have an order the language can't know: semantic versions, calendar versions, a vendor's release numbers. A host declares a type for them in the kind, and the ordering comes from Go:
// kind file
type Version ordered
fn semver(string) -> Version// policy
when semver(release.version) < semver("1.4.0") {
deny(reason: client_too_old)
}In a kind file, type Version ordered declares a type that's opaque, has no fields, and is ordered by the Go type's Compare(T) int method.
The Go side. The host registers the Go type explicitly, and names it for policies:
policy.WithOrdered[*semver.Version]("Version")The type needs a method
Compare(T) intthat returns a negative number, zero or a positive number, the conventiontime.Time,netip.Addrand most version libraries already follow. The method is mandatory:NewKindpanics if the type lacks it. It's never declared as anfnin the kind, so the kind file only says that the type is ordered, not how.Pointers. The registered Go type is exact. Most version libraries put
Compareon a pointer, so registering*semver.Versionmakes that pointer type the orderedVersion, and a field one pointer deeper,**semver.Version, is?Version. A nil value of a registered pointer type that reaches a comparison is a runtime error; a field that can really be missing should be declared one pointer deeper, as an optional. A type that isn't registered keeps its usual mapping even if it has aComparemethod, so adding a method in Go never changes the contract by itself.Text. Traces, errors and test output print a value as text.
NewKindpicks the method once, when the type is registered:MarshalTextfromencoding.TextMarshalerif the type has it, otherwiseStringfromfmt.Stringer. A type with neither makesNewKindpanic, because the fallback, Go's%v, can print a pointer's address and would break determinism. IfMarshalTextreturns an error for a value,Stringis used when the type has it, and otherwise the text is<Version: error text>, so printing a trace never fails an evaluation.JSON input.
sigil evalandsigil testread input throughencoding/json, so a field of the type decodes from a JSON string when the type implementsencoding.TextUnmarshaler(orjson.Unmarshaler). Nothing requires it, but without it an input file can't set the field.Operators.
<,<=,>,>=,==and!=callCompare. So doinand the list operators when the elements are of the type. Only values of the same type compare: aVersionnever compares with another ordered type or with a string.Opaque. A policy can't read inside the value, has no literal for it and can't declare a param of the type. Values come from inputs and host functions, such as
semverabove. Parsing stays with the host, so semver, calver or a custom scheme are all just Go.Errors. A string the parsing function rejects is that function's error, which is a runtime error.
Comparemust be a total order, pure and deterministic, the same contract as a host function.Map keys. An ordered type can't be a map key.
Why this and not a built-in version type is on Why the language looks like this.
Dynamic input
Status: a proposal, not on the roadmap yet. Resolver isn't an exported API.
Today a Go host decodes external input into its input struct before calling Eval, and the CLI uses its own JSON decoder. The proposal would let a host supply values by path instead:
type Resolver interface {
Get(path string) (Value, bool)
}The evaluator would check each resolved value against the kind's schema and reject a type mismatch with the failing path. The interface and how it integrates with Eval are undecided.
Host-function results in the trace
Status: a proposal, not on the roadmap yet. The trace records no host function calls today.
A logged input replays a decision with sigil eval because the same compiled policy and the same input give the same result. That holds only while every host function returns what it returned the first time. A function that looks something up in a live system, such as a registry or a directory, breaks it: replaying last week's input asks today's registry. When a host function is the better tool covers when a host takes that trade.
The proposal records every host function call an evaluation makes, its arguments and its result or error, in the result's trace. A recorded trace then has the shape of a test file's stubs, calls with args and returns, so replaying an evaluation means passing its input and its recorded calls as stubs, and sigil eval could read both from one log entry. Undecided: whether recording is on by default, since a function called inside a quantifier can be called once per element, and how a host keeps sensitive results out of a trace it logs.
Loading a kind at run time
Status: in progress on the roadmap as the LoadKind deliverable of the Hardening milestone. The internal kind-file loader and the Go binding it synthesizes already back the CLI and have fuzz coverage; the public API doesn't exist.
A public policy.LoadKind would let a Go service load a kind from its kind file at run time instead of defining the kind in Go. It needs a companion API to bind Go functions to the host functions a loaded kind file declares. Neither exists yet.
Until they do, a Go service that consumes a kind it doesn't define generates Go code from the exported kind file with sigil gen go, whose NewKind builds the kind from Go types with the service's own host function implementations, or it imports the defining host's package; Use a kind from another Go service shows the first. What LoadKind would add is a kind known only at run time, without a generate step.
Tooling reads the exported kind file through the CLI. The stock sigil binary checks policies against an exported kind file without the host's Go code, and evaluates them as long as no rule reaches a host function call; reaching one is a runtime error. A host binary built with pkg/cli supplies the real implementations. The facts are in Host functions and host binaries.
Module versioning
Status: a proposal, not on the roadmap yet. Modules have no version of their own.
A policy pins its kind's version, DeployApproval@2, but nothing pins a module. When a platform changes what one of its pub lets means, every importer gets the new meaning at its next reload, and Vocabulary is a public API explains why that's the dangerous kind of change.
A naming convention works today. A module name can end in a version, deploy.freeze.v2 in deploy/freeze/v2.sigil, and sit next to deploy.freeze in the same bundle, so the platform publishes the new meaning under a new name and each team moves its import when it's ready:
use deploy.freeze.v2.{is_frozen}A whole import binds the last segment, use deploy.freeze.v2 makes the names v2.is_frozen, so a selective import reads better. What a real module version would add over the convention is undecided: a version in the module header that imports pin, or a deprecation marker on a pub let that sigil check warns about in every importer. Either needs sigil breaking for modules to say when a new version is due.
sigil breaking for modules
Status: a proposal, not on the roadmap yet. sigil breaking compares two versions of a kind file only.
The proposal extends it to compare two versions of a module file and classify the changes to its exported surface, the pub lets:
| Change | Classified as | Why |
|---|---|---|
A pub let added | Compatible | No importer reads it yet |
A pub let removed or renamed | Breaking | Every document that imports it stops compiling |
A pub let's type changed | Breaking | Its importers' expressions stop type-checking |
A pub let's expression changed, with the same name and type | Changed meaning | Every importer compiles and decides differently, so it's reported for review rather than failed |
The last row is the one CI can't catch any other way. The platform's own tests pass, every team's sigil check passes, and decisions change.
sigil export for trusted modules
Status: a proposal, not on the roadmap yet. sigil export writes the kind file only.
A host binary exports its kind, so a policy repository can check team policies without the host's Go code. It doesn't export the host's trusted documents, such as a vocabulary module built with pkg/build and embedded in the service. A repository that imports deploy.freeze has to vendor a copy and list it under trusted, and nothing tells it when that copy goes stale.
The proposal links the trusted documents into the host binary the way cli.WithKind links the kind, and has sigil export write them into a directory next to the kind file, with --check failing on a stale copy as it does for the kind. The option that would link them doesn't exist in package cli yet.
Invocations in sigil lsp
Status: not implemented; tracked on the roadmap under Tooling II. The rest of the language server is: diagnostics, completion, hover, go-to-definition and formatting; see sigil lsp.
Two features would show what a policy invocation contributes where it's written:
- A code lens on each invocation summarizes it, for example "production: 1 approve, 1 review, gated by compliance != pci".
- Hovering an invocation shows its flattened rules, the same view as
sigil explain, scoped to that call.
Both read the flattened rules sigil explain computes, so the server would compile each invoked policy with the call's arguments bound.
explain --input
Status: not implemented; noted as planned on the roadmap with the sigil explain deliverable of the Composition milestone.
An --input flag on sigil explain would take an input document and mark, in the flattened output, which rules fired and which candidate won. Like sigil eval, it would need a host binary for policies that call host functions.
Cross targets for compile
Status: not implemented; tracked on the roadmap under Tooling II.
sigil compile copies the binary it runs as, so it writes binaries for its own platform only. A --from flag would name another binary to copy, such as the release binary of another platform, while the running one still checks the bundle:
sigil compile --out dist/gate-linux-amd64 --from sigil_linux_amd64/sigil --policy access.main- Before it writes anything,
compilereads the Go build information of both binaries withdebug/buildinfo, and compares the main module's path, version and VCS revision, and the version of every dependency. A pair that differs fails, naming the difference: the binary that checked the bundle and the one that will compile and evaluate it would be different builds of the engine. - A host binary compiles the same way, with its own build for the target as
--from, so the host's module and its kinds are compared too. - The
--frombinary is stamped as today, the ad-hoc signature of a darwin/arm64 binary included, so a Linux CI job could write binaries for Macs.
Until it exists, a release pipeline compiles on each platform it ships to, with that platform's sigil. Why the two binaries have to match: How compile works.
File names for multi-document files
Status: not implemented, and not on the roadmap yet.
The path-matches-name lint (see Lints and File names) expects a document named deploy.common in deploy/common.sigil. A file that holds several documents gets a warning for each one today, so deploy.sigil holding deploy.common and deploy.guardrails gets two.
The plan is a prefix rule: accept a file whose documents all share a prefix when its path is that prefix. deploy.sigil would then pass, because both of its documents start with deploy.
