diff --git a/lib/host/conformance.sh b/lib/host/conformance.sh
index f7742cad..dde8daba 100755
--- a/lib/host/conformance.sh
+++ b/lib/host/conformance.sh
@@ -93,6 +93,7 @@ MODULES=(
"lib/host/feed.sx"
"lib/host/relations.sx"
"lib/host/compose.sx"
+ "lib/host/execute.sx"
"lib/host/blog.sx"
"lib/host/page.sx"
"lib/host/server.sx"
@@ -108,6 +109,7 @@ SUITES=(
"feed host-fd-tests-run! lib/host/tests/feed.sx"
"relations host-rl-tests-run! lib/host/tests/relations.sx"
"blog host-bl-tests-run! lib/host/tests/blog.sx"
+ "execute host-ex-tests-run! lib/host/tests/execute.sx"
"session host-se-tests-run! lib/host/tests/session.sx"
"page host-pg-tests-run! lib/host/tests/page.sx"
"server host-sv-tests-run! lib/host/tests/server.sx"
diff --git a/lib/host/execute.sx b/lib/host/execute.sx
new file mode 100644
index 00000000..bd208bc2
--- /dev/null
+++ b/lib/host/execute.sx
@@ -0,0 +1,76 @@
+;; lib/host/execute.sx — the EXECUTE-fold: a SECOND interpreter over the SAME composition
+;; algebra (seq/alt/each) as the render-fold (lib/host/compose.sx), proving the algebra is
+;; domain-agnostic (plans/composition-objects.md step 7 — "prove universality with a second
+;; fold"). What changes between folds is only what the combinators + leaves MEAN:
+;;
+;; domain fold seq alt+when each leaf
+;; content render -> block order choose map items markup -> HTML string
+;; behaviour execute -> steps in order branch for-each effect -> effect log
+;;
+;; Crucially this REUSES compose.sx's shared machinery — the `when` predicate set
+;; (host/comp--pred?), the field/value resolver (host/comp--field), and the `each` source
+;; (host/comp--source). So the predicate set, the context-environment, and the iteration
+;; source are domain-agnostic; ONLY the leaf semantics (effect vs markup) and the fold's
+;; accumulator (a list of effects vs a string) are new. The behaviour model (Slice 9) is
+;; therefore "an execute-fold over a composition object", not a separate system.
+
+;; resolve an effect argument against the context: (field K) reads the :item/ctx value via
+;; the SAME resolver the render-fold uses; anything else is a literal.
+(define host/exec--arg
+ (fn (a ctx)
+ (if (and (= (type-of a) "list") (= (str (first a)) "field"))
+ (host/comp--field (first (rest a)) ctx)
+ a)))
+
+;; a leaf effect: (effect VERB ARG…) -> one effect record {:verb :args}. The execute-fold's
+;; analogue of a render leaf — it performs (records) an effect rather than emitting markup.
+(define host/exec--effect
+ (fn (verb args ctx)
+ (list {:verb (str verb) :args (map (fn (a) (host/exec--arg a ctx)) args)})))
+
+;; seq: run every step IN ORDER, concatenating their effects (the sequential strategy).
+(define host/exec--run-all
+ (fn (nodes ctx) (reduce (fn (acc n) (concat acc (host/exec--run n ctx))) (list) nodes)))
+
+;; alt: BRANCH — run the FIRST branch whose `when` holds (reusing the render-fold's
+;; predicate host/comp--pred?), else `else`. This is if/cond for the behaviour domain.
+(define host/exec--alt
+ (fn (branches ctx)
+ (if (empty? branches)
+ (list)
+ (let ((br (first branches)) (bh (str (first (first branches)))))
+ (cond
+ ((= bh "else") (host/exec--run (first (rest br)) ctx))
+ ((= bh "when") (if (host/comp--pred? (first (rest br)) ctx)
+ (host/exec--run (first (rest (rest br))) ctx)
+ (host/exec--alt (rest branches) ctx)))
+ (else (host/exec--alt (rest branches) ctx)))))))
+
+;; each: FOR-EACH — run the body per item from the (reused) source, :item bound, in order;
+;; depth guard backstops runaway recursion, same as the render-fold.
+(define host/exec--each
+ (fn (src body ctx)
+ (let ((depth (or (get ctx "depth") 0)))
+ (if (> depth 40)
+ (list {:verb "max-depth" :args (list)})
+ (reduce
+ (fn (acc item)
+ (concat acc (host/exec--run body (merge ctx {"item" item "depth" (+ depth 1)}))))
+ (list) (host/comp--source src ctx))))))
+
+;; the execute-fold (the interpreter): same combinator dispatch shape as host/comp--render,
+;; but leaves are effects and the accumulator is an effect log.
+(define host/exec--run
+ (fn (node ctx)
+ (if (not (= (type-of node) "list"))
+ (list)
+ (let ((h (str (first node))) (args (rest node)))
+ (cond
+ ((= h "seq") (host/exec--run-all args ctx))
+ ((= h "alt") (host/exec--alt args ctx))
+ ((= h "each") (host/exec--each (first args) (first (rest args)) ctx))
+ ((= h "effect") (host/exec--effect (first args) (rest args) ctx))
+ (else (list)))))))
+
+;; public entry: execute a composition node against a context -> the effect log (the run).
+(define host/exec-run (fn (node ctx) (host/exec--run node ctx)))
diff --git a/lib/host/serve.sh b/lib/host/serve.sh
index c271bc58..b676b5cb 100755
--- a/lib/host/serve.sh
+++ b/lib/host/serve.sh
@@ -87,6 +87,7 @@ MODULES=(
"lib/host/feed.sx"
"lib/host/relations.sx"
"lib/host/compose.sx"
+ "lib/host/execute.sx"
"lib/host/blog.sx"
"lib/host/server.sx"
)
diff --git a/lib/host/tests/execute.sx b/lib/host/tests/execute.sx
new file mode 100644
index 00000000..84c0c181
--- /dev/null
+++ b/lib/host/tests/execute.sx
@@ -0,0 +1,87 @@
+;; lib/host/tests/execute.sx — the EXECUTE-fold (lib/host/execute.sx): a second interpreter
+;; over the SAME seq/alt/each composition algebra as the render-fold, proving the algebra is
+;; domain-agnostic (plans/composition-objects.md step 7). Leaves are effects; the fold
+;; returns an effect log. Reuses compose.sx's when-predicates / field resolver / each source.
+
+(define host-ex-pass 0)
+(define host-ex-fail 0)
+(define host-ex-fails (list))
+(define host-ex-test
+ (fn (name actual expected)
+ (if (= actual expected)
+ (set! host-ex-pass (+ host-ex-pass 1))
+ (begin
+ (set! host-ex-fail (+ host-ex-fail 1))
+ (append! host-ex-fails {:name name :actual actual :expected expected})))))
+
+;; the verbs of an effect log, in order (effect records are {:verb :args}).
+(define ex-verbs (fn (effects) (map (fn (e) (get e :verb)) effects)))
+(define ex-args (fn (effects) (map (fn (e) (get e :args)) effects)))
+
+;; -- seq: steps in order --
+(host-ex-test "seq runs effects in order"
+ (ex-verbs (host/exec-run (quote (seq (effect a) (effect b) (effect c))) {}))
+ (list "a" "b" "c"))
+(host-ex-test "nested seq flattens in order"
+ (ex-verbs (host/exec-run (quote (seq (effect a) (seq (effect b) (effect c)) (effect d))) {}))
+ (list "a" "b" "c" "d"))
+
+;; -- alt + when: branch (reusing the render-fold's predicate set) --
+(host-ex-test "alt runs the first branch whose when holds"
+ (ex-verbs (host/exec-run (quote (alt (when (has "auth") (effect publish)) (else (effect hold)))) {"auth" "y"}))
+ (list "publish"))
+(host-ex-test "alt falls through to else when no when holds"
+ (ex-verbs (host/exec-run (quote (alt (when (has "auth") (effect publish)) (else (effect hold)))) {}))
+ (list "hold"))
+(host-ex-test "alt eq predicate branches on a context value"
+ (ex-verbs (host/exec-run (quote (alt (when (eq "role" "admin") (effect grant)) (else (effect deny)))) {"role" "admin"}))
+ (list "grant"))
+
+;; -- each: for-each over the (reused) source, with field resolution from the item --
+(host-ex-test "each runs the body per item (for-each)"
+ (ex-verbs (host/exec-run (quote (each (items {:email "a"} {:email "b"}) (effect notify))) {}))
+ (list "notify" "notify"))
+(host-ex-test "effect args resolve (field K) from the current item"
+ (ex-args (host/exec-run (quote (each (items {:email "a@x"} {:email "b@x"}) (effect notify (field :email)))) {}))
+ (list (list "a@x") (list "b@x")))
+(host-ex-test "effect args resolve (field K) from the context, and literals pass through"
+ (ex-args (host/exec-run (quote (seq (effect log (field :who) "done"))) {"who" "alice"}))
+ (list (list "alice" "done")))
+
+;; -- robustness: non-effect leaves / unknown heads produce no effects --
+(host-ex-test "a non-list node yields no effects"
+ (host/exec-run "bare" {}) (list))
+(host-ex-test "an unknown combinator head yields no effects"
+ (host/exec-run (quote (frobnicate 1 2)) {}) (list))
+
+;; -- the KEYSTONE: ONE control skeleton, folded TWO ways. Same alt+when, same context, the
+;; SAME branch is chosen (both use host/comp--pred?); render emits HTML, execute emits an
+;; effect. The composition algebra is domain-agnostic — render and behaviour are two folds. --
+(host-ex-test "same skeleton folds two ways — render picks the branch, execute picks the SAME branch (authed)"
+ (let ((ctx {"auth" "y"}))
+ (list (host/comp-render (quote (alt (when (has "auth") (text "in")) (else (text "out")))) ctx)
+ (ex-verbs (host/exec-run (quote (alt (when (has "auth") (effect enter)) (else (effect leave)))) ctx))))
+ (list "in" (list "enter")))
+(host-ex-test "same skeleton folds two ways — the else branch agrees across folds (anon)"
+ (let ((ctx {}))
+ (list (host/comp-render (quote (alt (when (has "auth") (text "in")) (else (text "out")))) ctx)
+ (ex-verbs (host/exec-run (quote (alt (when (has "auth") (effect enter)) (else (effect leave)))) ctx))))
+ (list "out" (list "leave")))
+
+;; -- a small workflow: validate -> (branch on status) -> notify each recipient. Proves the
+;; behaviour model is just an execute-fold over a composition object. --
+(host-ex-test "a publish workflow runs as one execute-fold over the composition"
+ (ex-verbs
+ (host/exec-run
+ (quote (seq
+ (effect validate (field :slug))
+ (alt (when (eq "status" "ready") (effect publish (field :slug)))
+ (else (effect hold (field :slug))))
+ (each (items {:to "a"} {:to "b"}) (effect notify (field :to)))))
+ {"slug" "post-1" "status" "ready"}))
+ (list "validate" "publish" "notify" "notify"))
+
+(define host-ex-tests-run!
+ (fn ()
+ {:total (+ host-ex-pass host-ex-fail)
+ :passed host-ex-pass :failed host-ex-fail :fails host-ex-fails}))
diff --git a/plans/composition-objects.md b/plans/composition-objects.md
index 133ef846..a2a42f93 100644
--- a/plans/composition-objects.md
+++ b/plans/composition-objects.md
@@ -125,11 +125,15 @@ Transclusion = a `ref` leaf. Sort/filter/limit/group = the *source query* langua
`POST /:slug/blocks/{add,:cslug/remove,:cslug/move}` (guarded, SX-htmx outerHTML swap).
Per-block field editing is free — a card is an object, edited via its own `//edit`.
(Live SX-htmx swap still wants a Playwright check; `alt`/`each` block insertion deferred.)
-7. **Prove universality with a second fold.** Write a tiny `execute`-fold over the *same*
- `seq/alt/each` structure that *runs* a workflow (leaves = effects; `seq` = steps in order, `alt`
- = branch, `each` = for-each) — the way the recursive tree proved recursion, this proves the
- composition algebra is domain-agnostic. Then the *behaviour* model (Slice 9) is "an `execute`-fold
- over a composition object", not a separate system.
+7. **(done)** Prove universality with a second fold. `lib/host/execute.sx` is an `execute`-fold
+ over the *same* `seq/alt/each` structure: leaves = effects, `seq` = steps in order, `alt`+`when`
+ = branch, `each` = for-each; the fold returns an effect log. It REUSES compose.sx's shared
+ machinery — `host/comp--pred?` (when), `host/comp--field` (field/value), `host/comp--source`
+ (each source) — so only the leaf semantics + accumulator differ. KEYSTONE proven (tests): ONE
+ `(alt (when …) …)` skeleton + ONE context folds two ways — render picks the branch → HTML,
+ execute picks the SAME branch → effect. A publish workflow (validate→branch→notify-each) runs as
+ one execute-fold. The behaviour model (Slice 9) is "an execute-fold over a composition object",
+ not a separate system. 13/13 (execute suite). Wired into conformance + serve.
8. **Factor out the shared machinery** once two folds exist: the fork model (ordered, labelled,
`when`), the combinator dispatch, the context-environment, and recursion become a reusable
`compose` core; each domain (`render`, `execute`, `eval`, …) supplies only its leaf + combinator