Spec and Codegen (the counter path)
The abstract App has function fields and cannot be serialized or codegen'd directly. Spec is its defunctionalized image: the model becomes a key→Int store, messages are tags (strings), and update is a finite handler table — all data. This dataful representation is the bridge between proof and emission.
The algebra layer: Op, Handler, Spec
The minimal serializable update primitive is Op — three cases:
inductive Op where
| incr
| decr
| setTo : Int → OpOp.apply evaluates an op against a field value:
def Op.apply : Op → Int → Int
| .incr, n => n + 1
| .decr, n => n - 1
| .setTo v, _ => vA Handler pairs a message tag (a string) with the operation to apply:
structure Handler where
tag : String -- message identifier (e.g., "inc")
field : String -- which field to mutate
op : Op -- which operationA Spec is the complete reflected app: initial state, UI tree, and handler table:
structure Spec where
init : List (String × Int) -- initial field values
view : Ui String -- the UI, with string tags
update : List Handler -- the message handlersThe Counter example shows this concretely:
def counter : Spec where
init := [("count", 0)]
view := .col [ .button "-" "dec", .bind "count", .button "+" "inc" ]
update := [ ⟨"inc", "count", .incr⟩, ⟨"dec", "count", .decr⟩ ]Store operations: specGet and specSet
The model is a List (String × Int) association list. Two helpers manage it:
def specGet (m : List (String × Int)) (k : String) : Int :=
(m.lookup k).getD 0
def specSet (m : List (String × Int)) (k : String) (v : Int) : List (String × Int) :=
if m.any (fun p => p.1 == k) then
m.map (fun p => if p.1 == k then (k, v) else p)
else m ++ [(k, v)]specGet reads a field, defaulting to 0 if absent. specSet writes a field, inserting if absent, updating if present.
The update denotation: Spec.step
Spec.step is the operational semantics — it folds one message tag into the store:
def Spec.step (s : Spec) (msg : String) (m : List (String × Int)) : List (String × Int) :=
match s.update.find? (fun h => h.tag == msg) with
| some h => specSet m h.field (Op.apply h.op (specGet m h.field))
| none => mIt looks up the handler by tag, and if found, applies the op to the named field. Unknown messages are no-ops.
The proof guarantee: Spec.toApp
The whole point of Spec is that it typechecks as a lawful abstract App:
def Spec.toApp (s : Spec) : App (List (String × Int)) String where
init := s.init
update msg m := (s.step msg m, [])
view _ := .text ""This is not a runtime interpretation or a generated stub. The typechecker verifies that Spec is an App. That typecheck is the guarantee: a Spec is a command-free (pure) instance of TEA, so every generic theorem proven for App — reload equivalence, message-log append law — holds for it without new proof.
Code emission: Codegen.emit
The codegen pipeline is three steps: JSON serialization, JS runtime generation, and HTML wrapping.
Step 1: Serialize the Spec to JSON. Spec.toJson encodes the entire spec:
def Spec.toJson (s : Spec) : String :=
"{\"init\":" ++ specModelJson s.init
++ ",\"view\":" ++ uiToJson s.view
++ ",\"update\":[" ++ String.intercalate "," (s.update.map Handler.toJson) ++ "]}"Step 2: Emit the runtime. runtimeJs is the JS mirror of Spec.step plus DOM rendering:
def runtimeJs : String := String.intercalate "\n" [
"let model = Object.assign({}, APP.init);",
"function step(msg){",
" var h = APP.update.find(function(x){ return x.tag === msg; });",
" if(h){ var cur = model[h.field] || 0;",
" model[h.field] = h.op==='incr' ? cur+1 : h.op==='decr' ? cur-1 : h.n;",
" render(); }",
"}",
/* ... DOM rendering functions ... */
]Step 3: Wrap in HTML. Codegen.emit generates a complete, self-contained SPA:
def Codegen.emit (s : Spec) : String := String.intercalate "\n" [
"<!doctype html>",
"<html><head>…</head><body>",
"<div id='app'></div>",
"<script>",
"const APP = " ++ s.toJson ++ ";",
runtimeJs,
"</script>",
"</body></html>" ]The output is one file with no dependencies, no build step, no framework.
Scope and boundaries
What is proven: Spec.toApp typechecks, so the Lean Spec.step semantics are lawful TEA. Every theorem about App (reload, log append) is inherited.
What is checked, not proven: The JS runtime is not proven to equal the Lean interpreter. What exists instead is empirical: the same corpus replays on multiple runtimes (happy-dom, headless Chromium, React-Native-web) and the algebraic post-conditions are re-asserted there. See What is proven for the full refinement picture.
What is out of scope: The Workflow DSL is designed for ReflApp + Doc apps (like the hello example). Spec apps (like Counter) use a different state model and are not portable to the workflow framework without a rewrite. The full pipeline with effects, bindings, and multiuser is ReflApp + AppGen — a separate codegen path not covered here.
Recap
Op— three serializable update primitives: increment, decrement, set-to-value.Handler— binds a message tag to an op and a field name.Spec— the reflected app: initial state, UI tree, and handler table.Spec.step— the update denotation; folds a message into the store.Spec.toApp— typechecks as a lawfulApp, inheriting allApptheorems.Codegen.emit— serializes to JSON, wraps in a generic JS runtime, outputs a complete SPA.
Next steps
- The Counter example — see both the proven
Specand the typed-messageApp Int CMsgform. - What is proven — understand the refinement boundary and what the JS runtime fidelity claim covers.
- Host bindings — move from pure data apps to store-backed apps with effects.