zigttp can statically prove your handler function is correct at compile time. Enabled via -Dverify at build time, zero cost when disabled.
# Build with verification
zig build -Dhandler=handler.ts -Dverify
# Combine with other flags
zig build -Dhandler=handler.ts -Dverify -Daot -Doptimize=ReleaseFastVerification runs after parsing and before bytecode generation. If any error-severity diagnostic is found, the build fails with a non-zero exit code. Warnings are reported but do not fail the build.
zigttp's JavaScript subset bans most sources of non-trivial control flow:
- No
while/do-while(no back-edges) - No
try/catch(no exceptional paths) - No
goto, no labeled statements
break and continue are allowed within for-of loops. Both are forward jumps only (break jumps past the loop end, continue jumps to the next iteration) and do not introduce back-edges, so the verification invariant holds.
The only control flow is: if/else (forward branching), match (exhaustive forward branching), for-of (bounded iteration with break/continue), assert (guard with early return), and return (function exit). The IR tree IS the control flow graph. Verification is a recursive tree walk, not a fixpoint dataflow analysis.
Every code path through the handler must return a Response. The verifier recursively determines whether each statement always, never, or sometimes returns:
return- always returnsifwithoutelse- sometimes (even if the then-branch returns)if/elsewhere both branches return - always returnsfor-of- never (iterable could be empty)var_decl,expr_stmt- never returns
Triggers when: the handler body does not always return on every code path.
verify error: not all code paths return a Response
--> handler.ts:2:17
|
2 | function handler(req) {
| ^
= help: ensure every branch (if/else) ends with a return statement
Fix: add else clauses, default cases, or trailing return statements.
Values from Result-producing virtual module calls (jwtVerify, validateJson, validateObject, coerceJson, decodeJson, decodeForm, decodeQuery) must have .ok checked before .value or .unwrap() is accessed.
The verifier tracks result bindings through the control flow tree:
import { jwtVerify } from "zigttp:auth";
function handler(req) {
const token = req.headers.authorization;
const result = jwtVerify(token, secret, "HS256");
// ERROR: accessing result.value without checking result.ok
return Response.json(result.unwrap());
}verify error: result.value accessed without checking result.ok first
--> handler.ts:7:28
|
7 | return Response.json(result.unwrap());
| ^
= help: check result.ok before accessing result.value:
if (result.ok) { ... result.value ... }
Recognized patterns for .ok checks:
if (result.ok) { ... }- direct truthinessif (result.isOk()) { ... }- method callif (result.ok === true) { ... }- strict equalityif (!result.ok) { return ...; }- negated early return (code after is safe)if (result.isErr()) { return ...; }- error early return
Statements after an unconditional return in a block. Severity: warning.
verify warning: unreachable code after return statement
--> handler.ts:4:5
|
4 | const x = 42;
| ^
= help: remove the unreachable code, or restructure the control flow
Declared variables that are never referenced. Severity: warning. Suppress by prefixing the name with _.
The check is scope-aware: a variable used only in a nested scope that shadows an outer declaration does not count as a use of the outer binding.
verify warning: unused variable 'temp'
--> handler.ts:3:11
|
3 | const temp = computeValue();
| ^
= help: remove the variable, or prefix with _ to suppress: _temp
Match expressions without a default arm. Severity: warning.
verify warning: match expression has no default arm
--> handler.ts:5:12
|
5 | return match (req) {
| ^
= help: add a default arm: default: Response.text("Not Found", { status: 404 })
Four virtual module functions return optional values (T | undefined):
env("KEY")- returnsstring | undefinedcacheGet("ns", "key")- returnsstring | undefinedparseBearer(header)- returnsstring | undefinedrouterMatch(routes, req)- returnsobject | undefined
The verifier tracks these optional bindings and requires them to be narrowed before use. Using an optional value as a function argument, object property value, template literal expression, or in arithmetic/string concatenation without first checking for undefined is an error.
import { env } from "zigttp:env";
function handler(req) {
const appName = env("APP_NAME");
// ERROR: optional value used without checking for undefined
return Response.json({ app: appName });
}verify error: optional value used without checking for undefined
--> handler.ts:6:30
|
6 | return Response.json({ app: appName });
| ^
= help: check before use: if (val !== undefined) { ... }
or provide a default: val ?? "fallback"
Property access on an un-narrowed optional_object (from routerMatch) is also an error:
verify error: property access on optional value without checking for undefined
Recognized narrowing patterns:
if (val) { ... }- truthiness narrows in then-branchif (!val) { return ...; }- negated early return narrows subsequent codeif (val !== undefined) { ... }- explicit check narrows in then-branchif (val === undefined) { return ...; }- explicit check with early returnconst x = env("KEY") ?? "default"- nullish coalesce resolves at declarationval = "override"- reassignment to non-optional clears trackingval?.prop- optional chaining is safe (not flagged)
The verifier detects module-scope variable mutations inside the handler body. Since handlers are re-invoked per request with fresh scope, mutating a module-level let binding would leak state between requests.
The check walks all assignment nodes in the handler body. If the target is an identifier whose binding has a scope_id less than the handler's scope_id (meaning it's declared at module level), the verifier emits a module_scope_mutation error.
// ERROR: handler mutates module-scope variable
let counter = 0;
function handler(req: Request): Response {
counter += 1; // verify error: module_scope_mutation
return Response.json({ count: counter });
}Fix: use const for module-level declarations, or move mutable state to zigttp:cache.
The result feeds into HandlerProperties.state_isolated. When no module-scope mutations are detected, state_isolated is proven true, enabling safe multi-tenant handler sharing.
The verifier resolves the handler's active spec set and discharges each
name against the classified HandlerProperties. When the handler declares
no Spec<...>, every supported v1 spec is active by default. A
Spec<...> on the handler return type narrows the active set to exactly
the names in the annotation. The machinery lives in spec_discharge.zig
and runs after the analyzer pipeline so it has access to the full property
set plus the imported module list.
import type { Spec } from "zigttp:types";
type Guardrails = Spec<"idempotent" | "deterministic">;
function handler(req: Request): Response & Guardrails {
return Response.json({ now: Date.now() });
}Three diagnostic codes:
- ZTS500 - spec_not_discharged: the corresponding property field is
false. Cause-only specs (
deterministic,read_only,retry_safe,idempotent,state_isolated,fault_covered,pure,stateless,result_safe,optional_safe) include a per-propertyTry:suggestion. Counterexample-rich specs (no_secret_leakage,no_credential_leakage,input_validated,pii_contained,injection_safe) include a falsifying request body. - ZTS501 - spec_incompatible_with_import: the spec contradicts an
imported module. v1 fires for
Spec<"read_only">againstzigttp:cacheorzigttp:sql. ZTS500 is suppressed for the same name so the agent does not enter repair against a contradiction. - ZTS502 - spec_unknown_name: the declared name is not in the v1 set.
Diagnostics are stored on HandlerContract.spec_diagnostics. Surfaces:
the live HUD, the proof studio (failing pills expand inline to the
ZTS5xx code, source line, and snippet), the proof ledger
(declaredSpecs: [{name, discharged, diagnosticCode?, diagnosticMessage?, sourceLine?, sourceColumn?, sourceSnippet?}] per
swap event; the diagnostic fields appear only on failed specs),
zigts check --json (declared_specs as the effective active set and
spec_diagnostics arrays), and the pi_specs_status agent tool. See
user-guide.md for the author-side
view.
Spec discharge also runs per helper. A Proof<T, "..."> annotation on a
helper's return type is discharged against the facts effect inference
and path-return analysis prove about that function (function_specs.zig),
for the v1 capsule set total, pure, read_only, deterministic.
Helper failures reuse ZTS500 / ZTS502, each carrying a function
attribution. A helper that breaks a property the handler's Spec<...>
demands while carrying no capsule for it gets ZTS606 -
missing_capsule: the proof cannot compose across that call boundary.
computeProperties intersects the handler's classified properties with
its call-graph-composed effect row, so a property the handler claims
also accounts for every helper it transitively calls. zigts check --json adds a proofCapsules array; see
zigts-expert-contract.md.
Counterexample-rich specs additionally feed a persistent on-disk
corpus. Each falsifying input the analyzer materialises is written
under .zigttp/witnesses/<short_hash>/ so the same logical leak does
not need to be rediscovered next session. See witnesses.md
for layout, CLI (zigttp witnesses), and agent tool (pi_witnesses).
Effects<T, "..."> is the capability dual of Proof<T, "...">. Where a
proof property declares a guarantee, an Effects<...> annotation
declares a ceiling: the function's inferred capability row may be no
wider than the named set. Discharge is the inverse direction of proof
discharge - checked inferred ⊆ declared against the row
effect_inference.zig computes from real call sites. A reached
capability outside the ceiling is ZTS503, an unknown capability name
is ZTS504, and a declared-but-unreached capability is the warning
ZTS505. The vocabulary is the runtime capability set (env,
clock, crypto, network, ...).
The same annotation on the handler's return type is a budget that
bounds every reachable helper. A capability the handler reaches directly
outside the budget is ZTS506; one a reachable helper introduces is
ZTS607, attributed to that helper (contract_builder.zig Phase 4c).
The declared budget is recorded in contract.json under
sandbox.declaredBudget. zigts check --json adds an effectCapsules
array alongside proofCapsules.
Effects<...> is opt-in - a function with no annotation gets no check.
The budget and every ceiling are discharged only against inferred facts
from real function bodies, never an assumed claim. The opt-in
zigts check --require-export-capsules docs mode additionally warns
(ZTS507 / ZTS508) when an exported helper carries no capsule.
Verified properties also control runtime behavior:
- Route pre-filtering: proven routes reject non-matching requests at the HTTP layer before entering JS (
contract_runtime.zig). - Response memoization: when a handler is proven
deterministic(no Date.now or Math.random) andread_only(no write-classified virtual module calls), GET/HEAD responses are cached in memory and served without JS execution. Cached responses include anX-Zigttp-Proof-Cache: hitheader (proof_adapter.zig).
Both rely on the same property that makes verification tractable: the IR tree is the control flow graph, with no back-edges and no exceptions.
The -Dgenerate-tests=true build option enables compile-time exhaustive path enumeration and fault coverage analysis.
The PathGenerator (packages/zigts/src/path_generator.zig) walks the handler's IR tree, forking at every branch point (if/match) and I/O success/failure boundary. Each fork produces a test case representing one complete execution path through the handler. This exploits the same property that makes verification tractable: the IR tree IS the control flow graph (no back-edges).
For Result-producing calls (jwtVerify, validateJson, decodeJson, etc.), the generator forks into both the ok: true and ok: false paths. For optional-producing calls (env, cacheGet, etc.), it forks into defined and undefined paths.
The FaultCoverageChecker (packages/zigts/src/fault_coverage.zig) analyzes the generated paths against FailureSeverity annotations on each virtual module function:
| Severity | Functions | Meaning |
|---|---|---|
critical |
jwtVerify, validateJson, validateObject, coerceJson, decodeJson, decodeForm, decodeQuery |
Auth/validation failures must not produce 2xx |
expected |
cacheGet, env |
Cache misses and missing env vars are normal degradation |
upstream |
fetchSync |
Upstream failures should be surfaced, not swallowed |
The checker warns when a critical I/O failure path (e.g., jwtVerify returning {ok: false}) produces a 2xx response. This is a structural pattern overwhelmingly correlated with bugs. Cache misses returning 200 are correctly classified as graceful degradation and do not trigger warnings.
Results appear in:
contract.jsonunder thefaultCoveragesectionHandlerProperties.fault_coveredflag- Deployment manifest tags (
zigttp:faultCovered) - Build report alongside other PROVEN/--- labels
bash tests/verify/run_tests.shThe test suite verifies expected diagnostics from handler files in tests/verify/.