host P0.2: publish-DAG + execute-fold runner + capability check (hypothesis confirmed)

The hypothesis test. FINDING: a synchronous business flow expresses NATURALLY as an EXECUTE-FOLD
composition (host/execute.sx: seq/effect/alt — the category branch IS 'alt'), NOT an artdag
DATAFLOW DAG (which has no control flow). So 'business logic = art-dag' holds at the ABSTRACTION
(both content-addressed op-DAGs) and is REFINED at the vocabulary: the synchronous control-flow
runner is the execute-fold (caps {effect,branch,each}); artdag is the dataflow sibling. Two
instances of one thing, run very differently — exactly the framing.

lib/host/flows.sx: capability typing (host/flow--node-cap/required-caps derive a DAG's capability
set from its node vocabulary; effect→effect, alt→branch, each→each, wait→suspend), the execute-fold
seam runner (advertises {effect,branch,each}), and host/flow--bind (required ⊆ advertised → derive
the runner, else fail-fast). host/blog--publish-dag (the publish workflow) + publish-ctx.

Verified: publish-DAG required-caps = {effect,branch} → binds to the sync runner; runs →
newsletter→[validate,digest] / urgent→[validate,notify] / other→[validate,skip]; a  node →
{suspend} → binds FAIL-FAST against the exec-runner (would need the Erlang runner, RA). Runner is
DERIVED, not chosen. flows 7/7, blog 203/203, full host conformance 591/591.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 14:18:08 +00:00
parent 8c48cac46f
commit e38a8381d4
7 changed files with 151 additions and 9 deletions

View File

@@ -128,6 +128,22 @@
:id (host/blog-cid slug) ;; the object's content CID
:object {:type "article" :slug slug
:category (host/blog--post-category slug)}}))))
;; P0.2: the publish WORKFLOW as an EXECUTE-FOLD composition (host/execute.sx) — the SYNCHRONOUS
;; business flow. Validate, then BRANCH on category (newsletter → build a digest, urgent → notify
;; now, else skip). Content flow (effect/alt), NOT dataflow — so it's the execute-fold, not artdag.
;; Its required capabilities are {effect, branch} (host/flow--required-caps) → binds to the sync
;; execute-fold runner (which advertises {effect, branch, each}). A `wait` node would add {suspend}
;; and fail-fast against that runner (requiring the Erlang runner, RA). Runs against a ctx built
;; from the activity's object.
(define host/blog--publish-dag
(quote (seq
(effect validate (field "slug"))
(alt (when (eq "category" "newsletter") (effect digest (field "slug")))
(when (eq "category" "urgent") (effect notify (field "slug")))
(else (effect skip))))))
;; the ctx a publish activity presents to the publish-DAG (string keys — preds read ctx by key).
(define host/blog--publish-ctx
(fn (activity) (let ((o (get activity :object))) {"category" (get o :category) "slug" (get o :slug)})))
;; ── render ──────────────────────────────────────────────────────────
;; A post's sx_content is SX element markup -> HTML via render-page (which supplies

View File

@@ -95,6 +95,7 @@ MODULES=(
"lib/host/compose.sx"
"lib/host/execute.sx"
"lib/host/behavior.sx"
"lib/host/flows.sx"
"lib/host/htmlsx.sx"
"lib/host/blog.sx"
"lib/host/page.sx"
@@ -114,6 +115,7 @@ SUITES=(
"blog host-bl-tests-run! lib/host/tests/blog.sx"
"htmlsx host-ht-tests-run! lib/host/tests/htmlsx.sx"
"behavior host-be-tests-run! lib/host/tests/behavior.sx"
"flows host-fl-tests-run! lib/host/tests/flows.sx"
"compose host-cp-tests-run! lib/host/tests/compose.sx"
"execute host-ex-tests-run! lib/host/tests/execute.sx"
"session host-se-tests-run! lib/host/tests/session.sx"

44
lib/host/flows.sx Normal file
View File

@@ -0,0 +1,44 @@
;; lib/host/flows.sx — behavior DAGs + CAPABILITY-typed nodes / capability-advertising runners
;; (plans/business-logic-fed-flows.md). P0.2 finding: a SYNCHRONOUS business flow is an EXECUTE-FOLD
;; composition (host/execute.sx: effect/alt/each — content-addressed control flow), NOT an artdag
;; DATAFLOW DAG (which has no branch). Both are "content-addressed op-DAGs" — two instances of one
;; abstraction, run very differently: the execute-fold runner (control flow, synchronous) vs the
;; artdag runner (dataflow, memoized/parallel). The DIFFERENCE is which capabilities their nodes
;; need. A node declares its capability; a runner ADVERTISES what it supports; the binder checks
;; required ⊆ advertised (fail fast); so the sync/durable/distributed choice is DERIVED from the DAG.
;; ── capability typing: a node kind → the capability it needs ──────────
(define host/flow--node-cap
(fn (h)
(cond ((= h "effect") "effect")
((= h "alt") "branch")
((= h "each") "each")
((= h "wait") "suspend") ;; a timer/suspend node — the execute-fold canNOT run it
(else nil))))
(define host/flow--uniq-concat
(fn (a b) (reduce (fn (acc x) (if (contains? acc x) acc (concat acc (list x)))) a b)))
;; the capability SET a composition requires — the union of its nodes' caps (walked recursively).
(define host/flow--required-caps
(fn (node)
(if (not (= (type-of node) "list")) (list)
(let ((self (host/flow--node-cap (str (first node))))
(kids (reduce (fn (acc c) (host/flow--uniq-concat acc (host/flow--required-caps c)))
(list) (rest node))))
(if (nil? self) kids (host/flow--uniq-concat (list self) kids))))))
(define host/flow--subset? (fn (a b) (reduce (fn (ok x) (and ok (contains? b x))) true a)))
;; ── the SYNCHRONOUS op-table runner = the execute-fold ────────────────
;; a seam runner {:capabilities :run}. It ADVERTISES {effect, branch, each} — the execute-fold
;; vocabulary. run: fold the composition (dag) against the env's :ctx → the effect log (as data).
(define host/flow--exec-runner
{:capabilities (list "effect" "branch" "each")
:run (fn (dag env) {:status "done" :effects (host/exec-run dag (or (get env :ctx) {}))})})
;; DERIVE the runner: bind a DAG to a runner iff its required capabilities ⊆ the runner's advertised.
;; Fails fast (a {:bind-error …}) rather than mysteriously at run time. This is where "simple in SX
;; / durable in Erlang / distributed in celery-sx" becomes a checkable property of the DAG.
(define host/flow--bind
(fn (runner dag)
(let ((need (host/flow--required-caps dag)) (have (get runner :capabilities)))
(if (host/flow--subset? need have) {:ok true :runner runner}
{:ok false :bind-error {:needs need :has have}}))))

View File

@@ -110,6 +110,7 @@ MODULES=(
"lib/host/compose.sx"
"lib/host/execute.sx"
"lib/host/behavior.sx"
"lib/host/flows.sx"
"lib/host/htmlsx.sx"
"lib/host/blog.sx"
"lib/host/server.sx"

View File

@@ -1182,6 +1182,24 @@
(list "urgent" "urgent"))
(host-bl-test "publish-activity of a missing post is nil"
(host/blog--publish-activity "nope-nope-nope") nil)
;; P0.2: the publish WORKFLOW as an execute-fold DAG — branches on category, needs {effect,branch},
;; binds to the synchronous execute-fold runner (derived, not chosen).
(host-bl-test "publish-DAG: category branch (newsletter→digest) via the execute-fold"
(begin
(host/blog-put! "pdag1" "P" "(article (h1 \"x\"))" "published")
(host/blog--set-field-values! "pdag1" {"category" "newsletter"})
(let ((act (host/blog--publish-activity "pdag1")))
(map (fn (e) (get e :verb))
(get ((get host/flow--exec-runner :run) host/blog--publish-dag {:ctx (host/blog--publish-ctx act)}) :effects))))
(list "validate" "digest"))
(host-bl-test "publish-DAG: urgent→notify now, other→skip"
(list (map (fn (e) (get e :verb)) (get ((get host/flow--exec-runner :run) host/blog--publish-dag {:ctx {"category" "urgent" "slug" "s"}}) :effects))
(map (fn (e) (get e :verb)) (get ((get host/flow--exec-runner :run) host/blog--publish-dag {:ctx {"category" "draft" "slug" "s"}}) :effects)))
(list (list "validate" "notify") (list "validate" "skip")))
(host-bl-test "publish-DAG requires {effect,branch} and binds to the sync runner (derived)"
(list (host/flow--required-caps host/blog--publish-dag)
(get (host/flow--bind host/flow--exec-runner host/blog--publish-dag) :ok))
(list (list "effect" "branch") true))
(define
host-bl-tests-run!

50
lib/host/tests/flows.sx Normal file
View File

@@ -0,0 +1,50 @@
;; lib/host/tests/flows.sx — P0.2: capability-typed nodes + the execute-fold (synchronous) runner.
;; A composition's required capabilities are DERIVED from its node vocabulary; a runner advertises
;; what it supports; the binder checks required ⊆ advertised (fail fast) — so the runner is derived.
(define host-fl-pass 0)
(define host-fl-fail 0)
(define host-fl-fails (list))
(define host-fl-test
(fn (name actual expected)
(if (= actual expected)
(set! host-fl-pass (+ host-fl-pass 1))
(begin (set! host-fl-fail (+ host-fl-fail 1))
(append! host-fl-fails {:name name :actual actual :expected expected})))))
;; ── required-caps: the node vocabulary → the capability set ──
(host-fl-test "required-caps: effect + alt → {effect, branch}"
(host/flow--required-caps (quote (seq (effect a) (alt (when (eq "k" "v") (effect b)) (else (effect c))))))
(list "effect" "branch"))
(host-fl-test "required-caps: each adds :each; wait adds :suspend"
(list (host/flow--required-caps (quote (each (query is-a t) (effect x))))
(host/flow--required-caps (quote (seq (effect a) (wait morning)))))
(list (list "each" "effect") (list "effect" "suspend")))
(host-fl-test "required-caps: a plain effect-only DAG needs only {effect} (zero ceremony)"
(host/flow--required-caps (quote (effect notify (field "to"))))
(list "effect"))
;; ── the binder DERIVES the runner: required ⊆ advertised, or fail fast ──
(host-fl-test "subset?: required ⊆ advertised"
(list (host/flow--subset? (list "effect" "branch") (list "effect" "branch" "each"))
(host/flow--subset? (list "suspend") (list "effect" "branch" "each")))
(list true false))
(host-fl-test "bind: an {effect,branch} DAG binds to the exec runner; a {suspend} DAG fails FAST"
(let ((ok (host/flow--bind host/flow--exec-runner (quote (alt (when (eq "k" "v") (effect a)) (else (effect b))))))
(bad (host/flow--bind host/flow--exec-runner (quote (seq (effect a) (wait m))))))
(list (get ok :ok) (get bad :ok) (get (get bad :bind-error) :needs)))
(list true false (list "effect" "suspend")))
;; ── the execute-fold runner folds a composition against the env :ctx → effect-as-data ──
(host-fl-test "exec runner: run a composition → {:status done :effects […]}"
(let ((r ((get host/flow--exec-runner :run) (quote (effect notify (field "to"))) {:ctx {"to" "alice"}})))
(list (get r :status) (map (fn (e) (get e :verb)) (get r :effects)) (get (first (get r :effects)) :args)))
(list "done" (list "notify") (list "alice")))
(host-fl-test "exec runner advertises {effect, branch, each}"
(get host/flow--exec-runner :capabilities)
(list "effect" "branch" "each"))
(define host-fl-tests-run!
(fn ()
{:total (+ host-fl-pass host-fl-fail)
:passed host-fl-pass :failed host-fl-fail :fails host-fl-fails}))

View File

@@ -126,15 +126,20 @@ fed-sx yet — those are adapter phases (RA/TA). Every piece swaps later; the DA
- [x] **P0.1 — publish-activity contract (SX side).** host/blog--publish-activity + post-category.
blog 200/200. NOTE: emits the next/-Erlang shape today; P0.4 reconciles to the canonical seam shape.
- [ ] **P0.2 — the publish-DAG + op-table runner + the CAPABILITY check.** Author the publish
workflow as an SX artdag DAG (validate → publish → notify/digest) whose nodes need only
`{effect, branch}`, + the op-table runner (advertises `{effect, branch, each}`). ACCEPTANCE
(the hypothesis test): (a) the workflow expresses NATURALLY as a DAG — if it's forced, that's the
signal the node vocabulary / representation needs rethinking BEFORE RA/TA; (b) artdag/analyze
computes the DAG's required-capability set = `{effect, branch}`; (c) the binder confirms required
⊆ op-table caps → runs; (d) run it → the expected effect-as-data. Then flip one node to `wait`
and confirm the bind FAILS FAST against the op-table runner (would require the Erlang runner, RA)
— proving durability is derived, not chosen.
- [x] **P0.2 — the publish-DAG + execute-fold runner + the CAPABILITY check. DONE 2026-07-02.**
**HYPOTHESIS-TEST FINDING:** the synchronous business flow expresses NATURALLY as an EXECUTE-FOLD
composition (host/execute.sx: seq/effect/alt — the branch on category IS `alt`, exactly what it's
for), NOT an artdag DAG — artdag is pure DATAFLOW with no control flow. So "business logic = art-
dag" is confirmed at the ABSTRACTION (both content-addressed op-DAGs) and REFINED at the vocabulary:
the SYNCHRONOUS control-flow runner is the execute-fold (caps {effect,branch,each}); artdag is the
DATAFLOW sibling (a different runner). Two instances of one thing, run very differently — as
predicted. Built: lib/host/flows.sx (host/flow--{node-cap, required-caps, subset?, exec-runner,
bind}); host/blog--publish-dag + publish-ctx. Verified: publish-DAG required-caps derived =
{effect,branch} → binds to exec-runner; runs → newsletter→[validate,digest]/urgent→[validate,
notify]/other→[validate,skip]; a `wait` node → required {suspend} → binds FAIL-FAST against the
exec-runner (would need Erlang, RA). flows 7/7, blog 203/203, conformance 591/591.
IMPLICATION for RA/TA: the Erlang runner isn't a "different flow language" — it's the SAME op-DAG
with +{suspend} nodes; RA is the runner that advertises suspend + wraps flow_dispatch.
- [ ] **P0.3 — wire the seam on the live host.** A local-SX trigger registry (on-publish →
publish-DAG), an in-process transport (a KV-backed log), the host as effect driver. In edit-submit
detect the draft→published TRANSITION (prev≠published & new=published — fire-once), build the
@@ -185,6 +190,12 @@ covers everything until a DAG's cost/latency/placement forces the substrate.
activities), so business logic can change state, which federates, which triggers more flows.
## Progress log (newest first)
- 2026-07-02 — P0.2 DONE + the hypothesis CONFIRMED (and refined). The synchronous publish workflow
is NATURAL as an execute-fold composition (seq/effect/alt), NOT artdag dataflow (no branch there).
So business-logic = art-dag holds at the abstraction (content-addressed op-DAG) but the SYNCHRONOUS
runner is the execute-fold, artdag the dataflow sibling — two instances, run differently, exactly
the framing. lib/host/flows.sx (capability layer + exec-runner + bind) + host/blog--publish-dag.
Runner DERIVED via required-caps ⊆ advertised; wait→fail-fast. flows 7/7, blog 203/203, 591/591.
- 2026-07-02 — folded in CAPABILITY-TYPED nodes / CAPABILITY-ADVERTISING runners. A node declares
`:needs` (wait→suspend, fan-out→parallel, heavy→offload); a runner advertises `:capabilities`
(op-table {effect,branch,each}; Erlang +suspend; celery-sx +parallel,retry,offload); artdag/analyze