Workflows and proofs
A Workflow is a named list of interaction steps plus the checks that must hold afterwards.
The same value is used two ways:
- Proof tier —
Workflow.holds app wis aBool, soby native_decideturns it into a theorem checked atlake buildtime. - Execution tier —
Workflow.toJson wrenders runner-shaped JSON that the replayers execute against the emitted artifacts on four runtimes.
Define once, prove once, run everywhere. The interpreter that runs the app is the interpreter that proves the test.
The DSL
structure Workflow where
name : String
users : List String := ["alice"]
steps : List WStep
checks : List WCheck := []
ui : List UICheck := []Steps — the two real interaction primitives
inductive WStep where
| fire : String → Payload → WStep -- click / nav → step(tag, payload)
| input : String → Atom → WStep -- typing → set a scalar
| back : WStep -- browser history backThese mirror the runtime exactly. input sets a scalar with no re-render, matching the
emitted oninput binding; fire dispatches and then renders, matching step. back is
identity in the proof — history is a runtime concern, not part of the Doc — and is genuinely
executed by the replayers.
Checks — provable post-conditions
inductive WCheck where
| scalarEq : String → Atom → WCheck -- scalar k equals v
| tableLen : String → Nat → WCheck -- table t has exactly n rows
| rowField : String → Nat → String → Atom → WCheck -- table t, row i, field f equals vAll decidable, which is what makes the theorem discharge by computation.
UI checks — replay-only DOM assertions
inductive UICheck where
| seesText : String → UICheck -- the visible text contains this
| namedControl : String → UICheck -- a control with this accessible name existsThese are not part of the native_decide proof — they are about the rendered page, which
the Doc model does not describe. The replayers evaluate them against the live DOM.
A complete example
def helloWorkflow : Workflow where
name := "hello-post"
users := ["alice"]
steps := [ .fire "connect" (.atom (.str "79be667ef9dcbbac")),
.input "draft" (.str "hello from node:test"),
.fire "submit" (.atom .null) ]
checks := [ .scalarEq "identity" (.str "79be667ef9dcbbac"),
.scalarEq "path" (.str "/feed"),
.scalarEq "draft" .null,
.tableLen "messages" 1,
.rowField "messages" 0 "body" (.str "hello from node:test") ]
theorem helloWorkflow_holds : helloWorkflow.holds helloReflected = true := by native_decideIf a handler or the interpreter drifts, this stops compiling. That is the difference between a test that fails in CI and a proof that fails the build.
The corpus
Collect workflows into a list and prove the whole thing at once:
def helloWorkflows : List Workflow := [helloWorkflow, helloMyPosts, helloConnect]
theorem helloWorkflows_all_hold :
helloWorkflows.all (·.holds helloReflected) = true := by native_decide
theorem helloWorkflows_single_user :
helloWorkflows.all (fun w => decide (w.users.length ≤ 1)) = true := by native_decide
def helloCorpus : List String := helloWorkflows.map (·.toJson)corpus_single_user is not decoration. Workflow.toJson emits steps as a single user, so if a
workflow ever declared two users the emitted corpus would silently misrepresent it. The theorem
makes that boundary explicit instead of implied.
Emitted JSON
{ "name": "hello-post",
"users": ["alice"],
"steps": [
{"as":"alice","do":"fire","tag":"connect","payload":"79be667ef9dcbbac"},
{"as":"alice","do":"input","field":"draft","value":"hello from node:test"},
{"as":"alice","do":"fire","tag":"submit","payload":null}
],
"checks": [
{"kind":"scalar","key":"identity","eq":"79be667ef9dcbbac"},
{"kind":"tableLen","table":"messages","eq":1},
{"kind":"rowField","table":"messages","i":0,"field":"body","eq":"hello from node:test"}
],
"ui": [] }Emit it from a small script and pipe it to a replayer:
~/.elan/bin/lake env lean --run test/emit-hello-corpus.lean > /tmp/corpus.json
node test/encapp-replay-dom.mjs dist /tmp/corpus.json
node test/encapp-replay.mjs dist /tmp/corpus.json # headless chromiumCoverage is enforced, not encouraged
A screen or handler with no workflow is untested surface, so the gates fail on gaps:
page-coverage.mjs— bijective. Every registered page must be reached by a workflow, and every path a workflow reaches must be a registered page.handler-coverage.mjs— every handler tag must be fired by some workflow. Aninput Xstep credits the input-binding handlerset:X.nav-integrity.mjs— every static nav target in a page view must be a registered page.
All three are app-generic: the same tools drive hello and the flagship app.
Honest boundary
reflRun has single-Doc semantics, so the proof tier is single-app-instance logic.
Multi-user and cross-enclave stories belong to the node layer; the emitted corpus carries a
users list so a multi-client runner can project it, but the theorem is about one instance.
Live behaviour that needs a real backend and real crypto is a separate corpus, kept separately
and deliberately not claimed as covered here.
Recap
- Workflows are single-value objects used as both
native_decidetheorems (proof tier) and JSON test specifications (execution tier) - The three step primitives (
fire,input,back) mirror runtime behavior exactly; checks are decidable post-conditions on state - Coverage gates prove bijection between registered pages and workflows, and between handlers and fire steps — untested surfaces fail the build
- The honest boundary: proofs are single-instance logic; multi-user and crypto-backed behavior belong to separate corpora
- Collect workflows into a list and prove all of them at once; the theorem is your guarantee that the emitted corpus is sound
Next steps
- Host bindings — how apps declare what they expect of their host
- Testing and coverage gates — the four verification tiers and why each one matters
- Testing an integrated app — tiers of evidence when a real backend is involved