Roadmap
About 13840 wordsAbout 46 min
2026-09-24
Cedric Specht
Sigil is built in milestones, each with a concrete exit criterion. The Go API comes before the tooling, because the defining host builds its kind from Go structs and never reads a kind file; only tooling does. Every milestone has met its exit criterion: the language, the Go API, composition, the CLI a policy repository needs, fuzzing across every layer and editor tooling are done. Public LoadKind is the one required deliverable left; host-ordered types and two optional tooling extras are the rest.
The language was specified in the docs before any of it was implemented, because syntax is cheap to change in a Markdown file and expensive to change once policies exist in the wild. Writing the reference pages first surfaced gaps the first sketch of the design glossed over, such as how optional structs get unwrapped. The docs now describe the implementation and mark what's still planned, and the open questions note which milestone each remaining one blocks.
Important Dates
Documentation-first design starts
The language design moves into this documentation site. Nothing is implemented; the docs are the specification until M1 closes.
Imports and policy invocation specified
use becomes import-only, modules hold the shared lets, a policy invokes another the way it calls a decision constructor, optionally under when, and the host protects its guardrails with policy.Require. One file can hold several documents.
Front end implemented
The lexer, the Pratt expression parser and the document parser land under internal/, with golden tests for every document form and every error message. M2 closes.
Kinds, the type checker and expression evaluation implemented
M3 closes: a policy is checked against its kind with a hint for every typo and type mismatch, and its expressions evaluate against the host’s Go structs.
Assertions, collecting kinds, lets and optional structs settled
A second pass over the open questions: assert("reason", cond) with input and outcome phases, collect one and collect all, scoped and pub lets, optional chaining with present, scalar-only equality, param bounds and kind version pins land in the parser and the checker.
Rules, decisions and the trace implemented
when blocks, constructors, precedence, the default, asserts and collecting kinds evaluate, and Compile, Eval, Result and Match land in pkg/policy.
Declared reasons and the resolution rule implemented
Kinds declare each decision’s reasons, so a misspelled reason fails to compile. exclusive names outcomes that can’t fire together, and resolution becomes fold, check exclusive, rank, count, which replaces the positional tie-break. M4 closes apart from host-ordered types.
Composition implemented
Modules, imports, invocation with bounded params, policy.Require with policy.From, the bundle loader for fs.FS and ConfigMaps, cycle detection and sigil explain. M5 closes.
v0.1.0 released
The first tagged release: the language, the Go API and composition.
Tooling I implemented
sigil fmt, check with lints and sigil.yaml, eval, test with *_test.yaml files, export, and the policytest and cli packages. The stock binary evaluates against the kind file and fails only when a rule calls a host function; a host builds its own binary with its functions linked in. M6 closes.
v0.2.0 released, and sigil on Homebrew
v0.2.0 ships the Tooling I commands, and from v0.2.1 on every release publishes the sigil CLI to the Specht Labs tap.
Hardening tests and fuzz campaigns implemented
Fuzz targets cover the language pipeline, kind export/import, Go bindings, input decoding, composition, decision resolution and tooling. CI runs smoke campaigns and a scheduled workflow supports longer runs. Printing, export, float validation, trusted filesystem and test-file parsing fixes accompany the tests. M7 stays open until its remaining deliverables and day-long fuzzing criterion have evidence.
deploygate, an example host service
examples/deploy-gates/ runs the deploy gate from these docs as a Go service: both kinds defined in Go, the guardrails embedded and required, team policies that reload from a directory, typed matching, policytest and ginkgo suites, and metrics, traces, logs and profiles for every decision. It’s the first host outside the test suite.
Evaluation performance measured
Evaluation allocates less, CI fails a pull request whose benchmarks regress, and the performance page records what compiling and evaluating cost.
List filters
filter a in approvers: a != requestor.name keeps a requestor off their own approver list without a host function. Payloads in outcome asserts, which would let a guardrail enforce the same rule across every team’s policies, join the roadmap as the next step.
Payloads in outcome asserts
outcome.<decision> gives asserts the candidates the host acts on, typed by their payload, so a required guardrail can check what every team’s policy granted, not only which decisions fired. The open question on decision values and outcome is settled.
WebAssembly module and TypeScript package
The engine builds as a WebAssembly module, and @spechtlabs/sigil runs it from TypeScript, so a host outside Go evaluates policies with Go’s semantics exactly. The package isn’t published yet.
Fuzzing reaches the WebAssembly engine, and the corpus is shared
FuzzCall sends any request to the engine the playground and the bindings call, FuzzEval fuzzes the host-function protocol, and FuzzStubs binds any stubs document. Evaluation is checked against constant folding for every constant type, not only ints. The inputs fuzzing finds now live on a fuzz-corpus branch, so every run, local or in CI, starts from what earlier runs found.
First clean extended fuzz campaign
The extended campaign fuzzes each of the 33 targets in a job of its own, publishes one summary, and opens an issue that says which target failed and why. Its first run found a formatter comment that moved on a second pass, a reserved area that could overwrite a Mach-O code signature, and a fuzz target that grew until its runner died; chasing that one found that Synthesize kept 2 KB per distinct kind for the life of the process. With those fixed, the next run fuzzed every target for an hour without a failure. With two hour-per-target runs on a local machine, that’s about 132 target-hours over two days, and the Hardening milestone’s fuzzing criterion is met.
Static cost analysis dropped
Sigil won’t estimate or budget evaluation cost. Cost grows only with the collections a policy walks, which the host bounds, and a deadline stops an evaluation that runs long. A budget would have needed collection limits and host-function costs in every kind, for a worst-case number the deadline already covers.
Tree-sitter grammar
editors/tree-sitter-sigil gives Neovim, and other editors built on tree-sitter, a parser for Sigil with highlighting, folds, indentation and text objects. Its tests hold it to the Go parser on every Sigil source in the repository.
Typed Go code from a kind file
sigil gen go turns an exported kind file into a Go package: the enums, struct types and input as Go types, a payload struct per decision for a type switch on Result.Value, a handle per decision and reason, and a NewKind that builds the kind. A second Go service evaluates policies with typed results without importing the host, and the kind it builds exports the kind file it came from byte for byte.
sigil breaking checks kind changes in CI
sigil breaking OLD_KIND_FILE NEW_KIND_FILE classifies every change between two versions of a kind file as compatible, breaking or breaking in behavior, and fails when version didn’t move or a breaking change didn’t raise accepts. Reviewers no longer check the two numbers by hand.
Language server
sigil lsp gives editors the diagnostics sigil check reports, completion, hover, go-to-definition and formatting, read from the exported kind file and the policies alone. That meets Tooling II’s exit criterion.
Neovim plugin
sigil.nvim sets Neovim up for Sigil from one lazy.nvim line: the filetype, the language server from the sigil binary in $PATH, and the tree-sitter parser, which nvim-treesitter builds from this repository at the revision the plugin pins.
Objectives
Replace hand-rolled YAML rule engines
Teams keep rebuilding YAML rule engines with label-selector matchers. Sigil succeeds when a real one, such as a production deploy gate, runs on Sigil instead and its existing test cases pass unchanged.
Readable on first contact, strict enough to trust with a deny
Any engineer should be able to read a policy without learning a new paradigm, and a typo must fail at compile time instead of silently switching a deny rule off.
Errors that tell you the fix
Every compile error carries a file, line and column, what went wrong and a concrete fix, quoting the kind's signature when a type or payload is wrong. Golden tests pin every message, so a refactor that makes an error less helpful shows up as a diff.
Policy repos that stand on their own
A team's policy repository lints, formats and tests in CI from the exported kind file alone, without importing the host's Go code.
Milestones
Language specification
Write the language down precisely enough that the parser, type checker and evaluator can be built against it, and settle the open questions that would otherwise be decided by whatever the first implementation happened to do.
Done when every open question that blocks M2 has an answer.
7 deliverables - 7 done
DONE
Language reference MUST
Lexical structure, policy files, expressions, types, decisions, evaluation semantics and kind files, stated as normative rules.
DONE
Formal grammar MUST
W3C-style EBNF for policy and kind files, with the operator precedence table.
DONE
Rationale, tour and guides SHOULD
The understanding pages, the getting-started path and the how-to guides, so reviewers can judge the design by reading policies rather than grammar.
DONE
Imports and policy invocation MUST
use becomes import-only, modules hold shared lets, policies are invoked like decision constructors and can be gated by when, and the host protects guardrails with policy.Require.
DONE
Answer the syntax-affecting open questions MUST
Settled with the parser: quantifier body extent, chained comparisons, keywords as field names, assert("reason", cond) with the reason first, collect one and collect all, scoped lets and pub let, optional chaining with ?. and present, param bounds as , min: value, max: value, has as the only map-key test, and kind version pins (Kind@N with accepts on the kind). Boolean operators are words, and like globs are * and ? only. Settled with the evaluator: several decisions per block are allowed. The questions still open block no implemented milestone.
DONE
Declared reasons, exclusive and the resolution rule MUST
Reasons are declared per decision in the kind and constructors name them, so a typo is a compile error and asserts and exclusive can name a reason. exclusive declares outcomes that can’t fire together, precedence approve: a > b ranks one decision’s reasons, and resolution is fold, check exclusive, rank, count: no position is read and nothing merges.
DONE
Published documentation site SHOULD
The VuePress site, built and deployed from docs/ on every push to main.
Expressions
The front end of the language. The parser is hand-written: recursive descent for statements and a Pratt parser for expressions. A generator like goyacc or ANTLR would get a working parser sooner, but a hand-written one knows exactly what it expected at each point and can say “did you mean tier?” instead of “syntax error near IDENT”. Every statement starts with a keyword or, for a policy invocation, with a name followed by (, so one token of lookahead is enough to pick a statement parser.
Done when golden tests cover every operator and every error message. They do: see internal/parser/testdata.
6 deliverables - 6 done
DONE
Lexer MUST
Identifiers, strings, raw strings, numbers, chained durations, comments and the --- document separator, with positions.
DONE
Pratt expression parser MUST
One binding power per level of the precedence table, including quantifiers and the non-associative comparison level.
DONE
AST with source positions MUST
DONE
Error messages with fix hints MUST
The file:line:col, caret and help: format used throughout the docs.
DONE
Golden tests MUST
Including document boundaries: kind: and policy: fields in a type body, a kind payload field, and resource.kind in a condition must never start a new document.
DONE
List filters SHOULD
filter x in xs: body keeps the elements of a list for which the body holds, with the list’s own type, so a policy can take the requestor off an approver list without a host function. It parses, checks and formats like a quantifier, and each quantifier or filter variable gets its own frame slot, so a let evaluated inside a quantifier body can’t overwrite an outer variable of the same name.
Types
Turn Go structs into a kind and check expressions against it. This is where the design’s main promise lands: a typo like service.teir fails at compile time instead of turning a deny rule off at runtime.
Done when typos and type mismatches fail at compile time with a hint.
8 deliverables - 8 done
DONE
Type model and kind contract MUST
The sealed type language and the kind model, with every validity rule from the kind-files page in one place, so a kind loaded from a file and one reflected from Go structs are checked the same way. Map keys follow Go’s rule: any scalar.
DONE
Kind file loader MUST
Loads a kind document into the model with positioned diagnostics and a did-you-mean hint for unknown types, and prints a kind back as canonical source, which is what Schema() exports and what the round-trip test compares.
DONE
NewKind reflection MUST
Walks the input struct, the payload structs and the host functions once and records the Go field paths and function values for the evaluator, so evaluation never touches reflect. Panics with every problem at once, like regexp.MustCompile.
DONE
Type checker MUST
Every expression gets a type by the operator tables, empty literals take theirs from context, lets are typed in dependency order with cycles reported, and constructors are checked against their decision’s payload. Unknown names and fields get a did-you-mean hint.
DONE
Evaluator over Go structs MUST
A checked expression compiles once into closures that read the host’s own Go values in place through the field paths NewKind recorded. Runtime errors point at the expression that failed.
DONE
Optional structs and presence MUST
release?.soak reads a field of an optional struct, and a ?. that finds its operand absent makes the rest of the chain absent, as in TypeScript. present x tests an optional without unwrapping it. Pointers to slices and maps are rejected, since an absent collection would read the same as an empty one.
DONE
Scoped and exported lets in the checker MUST
A let inside a when body is visible there only, can’t shadow anything and has a name unique in its document. A let is private unless it’s pub let, and a policy’s pub let can’t read a param.
DONE
Element comparison MUST
== works on scalars only and strings aren’t ordered. in, the list operators and has compare lists and maps structurally and reject struct elements, which the evaluator used to answer false for without saying so.
Policies
The first milestone with a user: a host compiles a policy against its kind and evaluates it to a decision it can act on, with a trace that says why.
Done when the deploy gate from the README compiles and evaluates through the Go API, and golden tests pin the outcome and trace for every rule on the evaluation page.
10 deliverables - 9 done, 1 to do
DONE
when blocks and decision constructors MUST
Blocks compile into closures; a nested block’s condition only runs while the enclosing ones hold, each constructor reached becomes a candidate with its payload evaluated into the host’s Go struct, and lets evaluate at most once per evaluation, on first use.
DONE
Precedence and default MUST
A collect one kind returns its highest-ranked candidate, or the default when nothing fires.
DONE
Collecting kinds MUST
A collect all kind returns every folded candidate without precedence, or every folded candidate at the top rank with precedence. Errors return an empty outcome. outcome holds the distinct decisions in that result. Decision[T].MatchAll reads the grants of one decision with typed payloads.
DONE
Assertions MUST
Input asserts, whose condition doesn’t read outcome, run before any rule; outcome asserts run once the outcome exists. A failing phase ends the evaluation with a *policy.AssertionError listing every failure of that phase, and an assert whose own condition, or an enclosing condition, raises a runtime error counts as failed with the error attached. Each when condition is evaluated once per evaluation, whichever phases need it.
DONE
Scoped lets at evaluation MUST
A let inside a when body is evaluated lazily, at most once per evaluation, and only when its body is reached.
TODO
Host-ordered types SHOULD
policy.WithOrdered[*semver.Version]("Version") and type Version ordered give a Go type with Compare(T) int the ordinary comparison operators, printed through MarshalText or String. Deploy gates often compare versions, so the first real host is likely to need it. Nothing else blocks it: kinds, the checker and the evaluator exist.
DONE
Payloads in outcome asserts SHOULD
outcome.review is the list of review candidates the host gets back, typed by the decision’s payload, so a guardrail can say all r in outcome.review: requestor.name not in r.approvers about every team’s policy at once. A reason narrows the list, and r.reason is a decision value. It holds what the host acts on in both collect modes: the top rank after folding, or the default. Candidates have no equality and no order, so a list of them can only be ranged over with any, all or filter; indexing it is a compile error. A failing outcome assert prints the payloads of the candidates it read.
DONE
Evaluation trace MUST
Every candidate, and for each candidate of the winning decision, which conditions held. Result.Policy names the evaluated policy; each candidate names the policy its rule is in, with a call chain ready for invocations.
DONE
Declared reasons and the resolution rule MUST
Reason blocks in kind files and NewDecision with reasons, bare-name reasons in constructors and the default, exclusive and scoped precedence, and resolution as fold, check exclusive, rank, count with *ConflictError. Replaced the positional tie-break the evaluator had.
DONE
Go API for compiling and evaluating MUST
Compile over a one-file bundle with every document checked, Params type-checked against the declarations, Eval with typed *CompileError, *RuntimeError and *AssertionError, and Match and MatchAll for typed payloads. Load reads an fs.FS through the bundle loader.
Composition
The templating story from Composition without templating. Until this lands, per-team variants still need copying. Invocation makes composition flexible, so sigil explain ships with it: what a composed policy actually does has to stay one command away.
Done when a team policy invokes the platform’s policies with custom params, under a condition, and the host rejects it when a required guardrail is gated.
8 deliverables - 8 done
DONE
param and let MUST
Params bound by invocation or by policy.Params, each value checked against the param’s type and its min and max when the policy compiles or loads. Invocation arguments are evaluated at compile time, so bounds are checked at the argument.
DONE
Modules and imports MUST
module files, whole-file, selective and aliased use, and only pub lets importable: a use that names a private let is a compile error.
DONE
Policy invocation MUST
Invocations at the top level and inside when, with named, type-checked arguments and the call site recorded in every candidate’s trace.
DONE
policy.Require MUST
Load option that fails compilation unless the named policies are reached through top-level invocations only, at any depth; a gated call is reported at the call.
DONE
Bundle loader MUST
Reads every document in every .sigil file of an fs.FS, indexes them by header name and rejects duplicates, so embed.FS, os.DirFS, a mounted ConfigMap and policy.MapFS all work. Uses fs.Stat, so the symlinked keys of a mounted ConfigMap load; a test builds the real ..data layout. Kind documents must match Schema().
DONE
policy.From MUST
Takes required policies, and everything they use, from a trusted source, and makes a bundle document that claims a trusted name a compile error.
DONE
Cycle detection MUST
The let, import and invocation graphs must be acyclic. An invocation needs a use, so an invocation cycle is an import cycle, reported once at the import that closes it.
DONE
sigil explain MUST
Flattens a policy into its guarded decisions, with every invocation inlined and params bound, as text or JSON. Reads the kind file and the bundle from files, directories or stdin; --input, which would mark the rules that fired, is planned.
Tooling I
Move checking into policy repositories, so teams find errors in CI rather than when the host loads their policy.
Done when a policy repo lints in CI without the host’s code.
6 deliverables - 6 done
DONE
sigil fmt MUST
One canonical style, like gofmt, that keeps comments and follows the author’s line breaks at and and or. Quantifier bodies whose top level is and, or or xor get parentheses, and Schema() prints kinds in the same style.
DONE
Kind export with Schema() MUST
sigil export in a host’s own binary writes the linked kind’s Schema(), and policytest.Schema fails go test when the checked-in kind file is stale.
DONE
sigil check MUST
Type-checks and compiles every document, with a base policy’s params left unbound, checks --require with --trusted and --policy roots, and prints diagnostics as text, JSON or YAML.
DONE
sigil eval and sigil test MUST
Inputs decode strictly: an unknown key is an error, a missing one is the zero value, durations are Sigil literals in strings. Test cases live in *_test.yaml files and expect a decision and reason with payload fields, a whole collect all outcome, or the asserts that fail. The stock binary binds every host function to one that fails when called; package cli builds a host binary with the real ones.
DONE
Lints SHOULD
unused-import, unused-let, shadowed-kind-name, gated-assert, gated-deny, duplicate-invocation, qualified-imports and path-matches-name, reported by sigil check, with levels set per repository in sigil.yaml.
DONE
policytest package for go test SHOULD
policytest.Run runs the sigil test files against a host’s kind, with its Go types and real host functions, one subtest per file and per case.
Hardening
Generated contracts check kind export/import. Fuzzing covers the lexer, parser and AST, formatter, checker, constants, kind model, Go bindings, evaluator, composition, decision resolution, diagnostics, the WebAssembly engine and tooling. Both arbitrary source and valid generated inputs exercise the language. Short campaigns run in CI; longer campaigns run on a schedule.
Done when a day of fuzzing finds no panics, and Import(Export(k)) == k holds. Both hold: four hour-per-target campaigns on October 4 and 5, about 132 target-hours, found five problems, all fixed, and the last campaign passed every target. Public LoadKind remains.
5 deliverables - 3 done, 1 in progress, 1 skipped
DOING
LoadKind MUST
The internal kind-file loader and synthesized Go binding already support the CLI and have fuzz coverage. Public dynamic policy.LoadKind and its function-binding API remain planned.
DONE
Round-trip property test MUST
Generated kind models and reflected Go payloads export and reload with equal contracts, with version acceptance normalized. Covers nested collections, optional structs, defaults, reasons, precedence, exclusivity and both collection modes.
DONE
Fuzzing across language and tooling layers MUST
38 native Go fuzz targets take arbitrary source, grammar fixtures and valid generated contracts, and check semantic oracles: evaluation agrees with constant folding, collection operators with set membership, resolution with its rule. They also cover the WebAssembly engine’s JSON requests and host-function protocol, host-function stubs, and the binary patching behind sigil compile. mise run fuzz discovers every target and starts from the shared corpus on the fuzz-corpus branch. CI runs five-second smoke tests on every pull request and preserves failing inputs. A weekly campaign fuzzes each target for an hour in a job of its own, pushes what it found to the corpus branch, and opens or updates a GitHub issue that names each failing target, what go test reported and how to replay it.
DONE
A day of fuzzing MUST
Four campaigns on October 4 and 5 fuzzed each of the 33 targets for an hour: two on a local Mac Studio and two on GitHub Actions, about 132 target-hours in all. They found a formatter comment that moved on a second pass, a reserved area that could overwrite a Mach-O code signature, an expression whose debug print nests past the parser’s limit, and a fuzz target that grew until its runner died, which led to Synthesize holding 2 KB per distinct kind. All are fixed: the three failing inputs are kept under testdata/fuzz, and TestSynthesizedTypesDontHoldNames pins the memory fix. The last campaign, run 37288297603, passed every target. The weekly campaign keeps running.
SKIP
Static cost analysis SHOULD
Dropped on 2026-10-06. Evaluation cost grows only with the collections a policy walks, and the host bounds those; a deadline stops an evaluation that runs long. A static budget would need collection limits and host-function costs in every kind for a worst-case estimate a deadline already covers. Why there’s no cost budget has the reasoning.
Tooling II
Editor support and cross-service consumption. It comes last because it depends on the kind file format being stable, and that format only settles once the earlier milestones have exercised it. sigil lsp, sigil gen go and sigil breaking all run.
Done when editor completion works from an exported kind file alone, which sigil lsp does.
9 deliverables - 7 done, 2 to do
DONE
sigil lsp (language server) SHOULD
Speaks the Language Server Protocol over stdio and reads each document’s project the way sigil check does, from the nearest configuration file, with open buffers over the files on disk. It publishes check’s diagnostics and lints, completes inputs, fields through optional chains and quantifier variables, functions, decisions, reasons, payload keys, params, enum values, imports and kinds in documents that don’t parse yet, shows a decision’s signature and every name’s type on hover, goes to definitions in the kind file and across imports, and formats like sigil fmt.
TODO
Invocation code lens and hover in sigil lsp MAY
A code lens on each policy invocation that sums up what it contributes, and a hover with its flattened rules, as sigil explain shows them for that call.
DONE
sigil gen go (typed code generation) SHOULD
Generates a Go package from a kind file: enums, struct types, the input struct, a payload type per decision, decision and reason handles, a Funcs struct for the host functions and a NewKind constructor whose Schema() is the kind file byte for byte. --check fails CI on stale generated code, and kind files Go can’t declare exactly are refused with a fix per declaration.
DONE
sigil breaking MUST
Compares two versions of a kind file, modeled on buf breaking, and classifies every change by the compatibility table: compatible, breaking, or breaking in behavior. It checks the kind header too: every change bumps version, a breaking change raises accepts to it, and neither goes down. Package internal/compat holds the comparison, fuzzed against a corpus of policies that must keep compiling after every change it calls compatible.
DONE
Evaluators outside Go SHOULD
cmd/sigil-wasm builds the engine as a WASI preview 1 module with a flat JSON ABI, and @spechtlabs/sigil wraps it for TypeScript with a kind builder, typed decisions and a Web Worker helper, so other languages run the Go engine instead of a port. Publishing the package is an open question.
DONE
sigil compile SHOULD
Checks a bundle as sigil check does and writes a copy of the binary with it compiled in, whose eval, explain, test and version need no policy or kind file. --policy narrows the bundle to one policy and what it uses. A host binary compiles kinds with host functions, and on macOS the ad-hoc signature is updated in place.
TODO
Cross targets for sigil compile MAY
compile --from would copy a release binary of another platform, after checking with debug/buildinfo that it’s the same build as the binary that checked the bundle.
DONE
Tree-sitter grammar SHOULD
editors/tree-sitter-sigil parses policy, module and kind files, multi-document ones included, for editors built on tree-sitter, with Neovim’s highlight, fold, indent, injection, locals and text object queries. A Go test parses every Sigil source in the repository and the documentation with it and fails unless its tree agrees with the Go parser’s.
DONE
Neovim plugin SHOULD
sigil.nvim, in its own repository, gives .sigil files a filetype with // comments and sigil fmt’s indentation, starts sigil lsp from the binary in $PATH through vim.lsp.enable, and registers the tree-sitter parser at a pinned revision with nvim-treesitter, which builds it on the first .sigil file. Under LazyVim the spec is one line, and :checkhealth sigil names a missing or outdated binary.
