Vox Language Rules — Phase 5: Effect System & Workflow Determinism (2026-05-09)
Phase 5 — Effect System & Workflow Determinism
Section titled “Phase 5 — Effect System & Workflow Determinism”Parent plan:
vox-language-rules-and-enforcement-plan-2026.mdDepends on: Phases 1, 2, 3, and the precursor warning rule from Phase 3 Task 7 (vox/effect/missing-net-decl). For agentic workers: REQUIRED SUB-SKILL: Use superpowers:writing-plans to break each post-Task-1 effect into its own child plan; superpowers:executing-plans to execute each.
Goal: Land the language’s effect system. Public functions declare @uses(<effect-set>), the compiler proves the closure (every transitive callee is in the closure), @pure is provable rather than asserted, workflow bodies forbid non-deterministic builtins, @uses(fs(read: "./data/**")) glob declarations are checked at compile time against literal paths, and the closed bare-keyword table from Phase 1 Task 12 is structurally enforced. Lock the language’s strongest “wrong programs unrepresentable” surface.
Architecture: A new crates/vox-compiler/src/typeck/effects/ module owns:
- Effect set definition. A closed enum
Effect = Net | Fs(FsCap) | Time | Random | Secret | Auth(Role). Builtin manifest (Phase 1 Task 3) gains aneffects: &'static [Effect]field per entry. - Effect inference. Bottom-up over the call graph. Pure leaves (no impure builtin calls, no effect-having callees) infer to empty set. Functions calling impure builtins or impure callees inherit the union.
- Effect checking. A function declared
@uses(S)must have its inferred set⊆ S. A function declared@puremust have inferred set= ∅. Mismatch is a typed error. - Workflow determinism rule. A function decorated
workflowmay not transitively call any builtin in{Time, Random, Net}. Those must be hoisted intoactivity-decorated callees. @uses(fs(read: "<glob>"))checking. Glob declaration in source becomes a constraint; literal-path call sites are matched against the glob set; non-literal paths are rejected (with an escape hatch:@uses(fs(read: "*"))).- Closed keyword table runtime check. Any code path that registers a new keyword at runtime (plugins, dyn loading) checks against the closed table from Phase 1 Task 12; mismatch traps.
This is sized for multi-quarter execution. Each effect ships independently as warning for two minor versions, then escalates to error. Partial completion is a usable improvement.
Out of scope for Phase 5:
- Higher-rank effect polymorphism (ML/Koka-style row variables) — not needed; effect sets are concrete.
@no_panicreachability proof (research-tier; descoped earlier).- Full taint type system for
@secret— Phase 6+ research; this phase ships the capability aspect (@uses(secret)) without taint propagation. - Effect-system rules for plugin-loaded code that bypasses the static checker (Phase 4 Task 7’s runtime trap covers).
Verification setup
Section titled “Verification setup”cargo test -p vox-compiler --lib typeck::effects::— per-rule unit tests.cargo test -p vox-compiler --test effect_inference_golden— input-.vox/expected-inferred-effect-set snapshots across many small cases.cargo test -p vox-compiler --test workflow_determinism_golden— workflow-violating examples.cargo run -p vox-cli -- check examples/golden/— should pass after burn-down PRs (Phase 3 Task 7 already started this).cargo run -p vox-cli -- check --explain vox/effect/missing-net-decl— must succeed; same for every paired ID.
Task 1: Effect-set foundations + builtin manifest annotations
Section titled “Task 1: Effect-set foundations + builtin manifest annotations”Files:
- Create:
crates/vox-compiler/src/typeck/effects/mod.rs - Create:
crates/vox-compiler/src/typeck/effects/inference.rs - Create:
crates/vox-compiler/src/typeck/effects/check.rs - Modify:
crates/vox-actor-runtime/src/builtins/manifest.rs(Phase 1 Task 3 schema) — populateeffects:field for every builtin - Modify:
crates/vox-actor-runtime/src/builtins/builtin_registry.rs— gain effect annotations as a new column in the registry table - Re-run:
cargo run -p xtask -- gen-builtinsto propagate annotations to all generated outputs
Why first: Every later task depends on the inference/check infrastructure. This task lands the closed Effect enum, the inference walker (returns inferred set per fn), and the check rule (errors on declared/inferred mismatch). At land time the check is gated to Effect::Net only — Tasks 2–6 add other effects one at a time.
Effect classification of existing builtins (curated in this task; ~150 builtins to annotate):
populi.*—Net,Time(latency observation)fs.read*,fs.write*—Fs(...)time.now,time.sleep—Timerandom.*—Randomvox_secrets.resolve—Secret,Net(some sources are remote)std.http.*—Netstd.print,std.println— empty (modulo aStdoutsub-effect we can defer)
Verify: cargo test -p vox-compiler --lib typeck::effects::inference::leaf_purity — a fn calling no impure builtins infers ∅.
Task 2: vox/effect/missing-net-decl escalation + vox/effect/unjustified-net-decl (the symmetric pair)
Section titled “Task 2: vox/effect/missing-net-decl escalation + vox/effect/unjustified-net-decl (the symmetric pair)”Files:
- Modify:
crates/vox-compiler/src/typeck/effects/check.rs— wireNet-effect checks - Modify:
crates/vox-code-audit/src/diagnostics/catalog.rs— escalatevox/effect/missing-net-decl(Phase 3 Task 7) fromnotetowarning; addvox/effect/unjustified-net-decl(warning at land) - Create:
docs/src/reference/diagnostics/effect-unjustified-net-decl.md - Create:
examples/golden/anti/effect-unjustified-net-decl.vox
Symmetric pair design:
vox/effect/missing-net-decl— fn transitively calls aNet-effect builtin but lacks@uses(net). Suggested fix: insert@uses(net).vox/effect/unjustified-net-decl— fn declares@uses(net)but transitively makes noNet-effect call. Suggested fix: remove the decorator.
Why symmetric: The audit’s [A.5] rule is one direction; the inverse is just as important for keeping @uses(...) declarations honest. LLM training on this corpus picks up the inverse rule “for free.” This is the LLM-target principle “symmetric error/fix pairs in diagnostic IDs” applied.
Severity ramp for both:
- Land both as
warning. - After one minor version of corpus burn-down,
missingescalates toerrorandunjustifiedstays aswarning. - After one further minor,
unjustifiedescalates toerror.
Verify: Symmetric golden tests for both directions; corpus burn-down PR fixes existing missing violations before escalation.
Task 3: Workflow determinism hard rule
Section titled “Task 3: Workflow determinism hard rule”Diagnostic: vox/workflow/non-deterministic-builtin (warning at land; error after one minor — high priority because ADR-019 promised this).
Files:
- Create:
crates/vox-compiler/src/typeck/rules/workflow_determinism.rs - Modify:
crates/vox-compiler/src/typeck/effects/check.rs— wire workflow-context check - Create:
docs/src/reference/diagnostics/workflow-non-deterministic-builtin.md - Create:
examples/golden/anti/workflow-non-deterministic-builtin.vox
Rule: Inside a workflow-decorated body, no transitive call to a builtin in {Time, Random, Net} is permitted. The non-deterministic operation must be hoisted into an activity-decorated callee.
Suggested fix: Cannot autofix the design. Diagnostic emits a worked example showing the hoist:
// vox:skip — workflow keyword is reserved (ADR-028); documents future syntax// BAD: time.now() inside workflow bodyworkflow fn process_order(id: OrderId) -> Result[Receipt, Err] { let started_at = time.now() // <-- vox/workflow/non-deterministic-builtin // ...}
// GOOD: hoisted into activity@activityfn record_started_at() -> Instant { time.now() }
workflow fn process_order(id: OrderId) -> Result[Receipt, Err] { let started_at = record_started_at() // activity is journalled; replay-deterministic // ...}Why high priority: ADR-019’s replay-determinism contract depends on this. Today it’s advisory; this task makes it law.
Verify: Synthetic workflow with time.now() fires; after hoist, passes.
Task 4: @uses(fs(read: "<glob>")) source-side declarations
Section titled “Task 4: @uses(fs(read: "<glob>")) source-side declarations”Diagnostic: vox/effect/fs-path-not-in-glob (warning at land; error after one minor).
Files:
- Modify:
crates/vox-compiler/src/typeck/effects/inference.rs—Fseffect carriesFsCap { read: Vec<Glob>, write: Vec<Glob> } - Modify:
crates/vox-compiler/src/lower/decorators/uses.rs— parse@uses(fs(read: "./data/**", write: "./out/**")) - Modify:
crates/vox-bounded-fs/src/lib.rs— runtime checker becomes the fallback for non-literal paths; literal paths checked at compile time - Create:
docs/src/reference/diagnostics/effect-fs-path-not-in-glob.md
Rule: A fn declared @uses(fs(read: "./data/**")) may only call fs.read with literal-or-derived paths matching the glob. Non-literal paths (e.g., from a runtime parameter) require either:
- An explicit escape:
@uses(fs(read: "*"))— wide open, requires reviewer sign-off via the suppression mechanism, OR - A constructor-prove pattern: the path is built from validated components.
Why a Vox-only win [A.60]: Rust can have a bounded-fs crate; only Vox can attach the policy to the function so the compiler proves which functions can violate it.
Suggested fix: None for missing decl (need human design). For literal path that doesn’t match the declared glob, suggest widening the glob or moving the file.
Verify: Synthetic fn @uses(fs(read: "./data/**")) calling fs.read("./other/x.txt") fires; calling fs.read("./data/users.json") passes.
Task 5: @pure proven transitively
Section titled “Task 5: @pure proven transitively”Diagnostic: vox/effect/pure-violated (warning at land; error after one minor).
Files:
- Modify:
crates/vox-compiler/src/typeck/effects/check.rs—@pureis the same rule as@uses()(empty set), implemented as a checked predicate over the inferred set
Rule: @pure fn f() requires inferred-effect-set = ∅. Any transitive call to an effecting builtin or effecting callee fires.
Why: Today @pure is a tag with no enforcement. After this task, the tag means something.
Suggested fix: Remove @pure (likely the right choice if the fn legitimately needs the effect), or hoist the impure call out.
Verify: Synthetic @pure fn f() { populi.complete(...) } fires; @pure fn f() { 2 + 2 } passes.
Task 6: Per-effect rules for Time, Random, Secret
Section titled “Task 6: Per-effect rules for Time, Random, Secret”Files: One detector module per effect, mirroring Task 2’s symmetric-pair pattern.
vox/effect/missing-time-decl↔vox/effect/unjustified-time-declvox/effect/missing-random-decl↔vox/effect/unjustified-random-declvox/effect/missing-secret-decl↔vox/effect/unjustified-secret-decl
Sequencing: One effect at a time, in the order above. Each lands as warning, has a corpus burn-down, escalates to error. Effort: ~2 weeks per effect, sequenced (not parallel — each consumes the previous one’s lessons).
Why this order:
Timefirst — smallest fan-out (few fns currently calltime.*).Randomnext — even smaller fan-out.Secretlast — largest fan-out (many fns callvox_secrets.resolve); benefits from lessons learned in (1) and (2).
Verify: Per-effect, per-direction symmetric golden tests.
Task 7: Closed keyword table runtime enforcement
Section titled “Task 7: Closed keyword table runtime enforcement”Files:
- Modify:
crates/vox-compiler/src/lexer/keywords.rs—KEYWORDSis already const after Phase 1 Task 12; this task adds the runtime check that any plugin attempting to register a new keyword via dynamic loading hits a typed trap. - Modify:
crates/vox-plugin-host/src/loader.rs— runtime keyword-registration calls check againstKEYWORDS - Create: diagnostic
vox/runtime/closed-keyword-table-violation
Why: Phase 1 Task 12 closes the table at compile time for in-tree code. This task closes it at runtime for dynamically-loaded plugins.
Verify: Synthetic plugin attempting to add a keyword → host traps with the diagnostic.
Task 8: ADR landing + AGENTS.md backlinks + where-things-live update
Section titled “Task 8: ADR landing + AGENTS.md backlinks + where-things-live update”Files:
- Create:
docs/src/adr/0NN-effect-system.md— the design ADR for@uses(...) - Create:
docs/src/adr/0NN-workflow-determinism-enforcement.md— refs ADR-019, lands the structural check - Modify:
AGENTS.md— add §“Effect System (Required)” with the rules + backlinks - Modify:
docs/src/architecture/where-things-live.md— add row forcrates/vox-compiler/src/typeck/effects/
Verify: ADRs render via vox-doc-pipeline --check; AGENTS.md update has backlinks to this phase plan and to the new ADRs.
Risks specific to this phase
Section titled “Risks specific to this phase”| Risk | Mitigation |
|---|---|
Effect inference is too permissive (allows impure calls in @pure) | Snapshot tests cover edge cases (recursion, mutual recursion, dyn dispatch — n/a since Vox lacks dyn dispatch today, which is why this phase is cheap now). |
| Effect inference is too restrictive (rejects legitimate code via false-positive transitive inheritance) | Each effect ships warning for two minor versions; collect FP reports via vox check --report-false-positive; iterate. The two-minor warning window is non-negotiable. |
Corpus burn-down is harder than expected (many fns need @uses(...)) | Codemod vox migrate add-effect-decls walks call graphs and inserts the smallest sufficient @uses(...) per fn. Conservative: never widens; if it can’t prove a tight set, it asks for human input. |
@uses(fs(...)) glob enforcement too strict for legitimate dynamic-path code | The @uses(fs(read: "*")) escape is honest about the wide capability; reviewer sign-off via suppression mechanism. Document the trade-off prominently. |
| Workflow determinism rule (Task 3) breaks existing workflows | Workflows in examples/golden/ are audited before the rule lands; the corpus burn-down PR is a Task 3 prerequisite, not a follow-on. |
| Plugin authors don’t know about closed keyword table runtime check (Task 7) | Document in docs/src/contributors/plugin-authors.md; the runtime trap message points to the doc. |
Per-effect rule sequencing (Task 6) gets de-prioritized after Net lands | Each effect’s child plan is its own TASK with its own retrospective; stalled work is visible in the top-level plan’s status table. |
Phase 5 acceptance gate
Section titled “Phase 5 acceptance gate”A phase this large doesn’t have a single completion gate. Each effect (Net, Time, Random, Secret, Fs glob) has its own gate:
- Net effect (Task 1+2): Symmetric pair shipped;
missingiserror;unjustifiediswarning. - Workflow determinism (Task 3):
vox/workflow/non-deterministic-builtiniserror; ADR-019’s check is now structurally enforced. - Fs glob (Task 4):
@uses(fs(read: "<glob>"))checked at compile time;vox-bounded-fsis fallback for non-literal paths only. -
@pureproven (Task 5):vox/effect/pure-violatediserror;@pureis provable, not asserted. - Time/Random/Secret effects (Task 6): All three have shipped symmetric pairs; all three
missingareerror. - Closed keyword table runtime check (Task 7): Plugin loader rejects new keyword registration.
- ADRs landed (Task 8): Effect-system ADR + workflow-determinism ADR.
- AGENTS.md §“Effect System (Required)” landed.
-
where-things-live.mdupdated. - Per-effect retrospectives appended at each gate.
Phase 5 closes when all six gates above are met. Realistic horizon: 6–9 months from Phase 1 land, depending on Net-effect burn-down velocity.
Retrospective (per gate)
Section titled “Retrospective (per gate)”Appended within 5 working days of each gate’s completion. Capture: actual vs estimated effort per effect, scope changes, what surprised the team, whether the symmetric-pair design (Task 2) helped LLM corpus learning measurably.