diff --git a/lib/host/blog.sx b/lib/host/blog.sx index 17369cc8..d040d130 100644 --- a/lib/host/blog.sx +++ b/lib/host/blog.sx @@ -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 diff --git a/lib/host/conformance.sh b/lib/host/conformance.sh index 81ad3410..0aabeba9 100755 --- a/lib/host/conformance.sh +++ b/lib/host/conformance.sh @@ -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" diff --git a/lib/host/flows.sx b/lib/host/flows.sx new file mode 100644 index 00000000..6c4bb541 --- /dev/null +++ b/lib/host/flows.sx @@ -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}})))) diff --git a/lib/host/serve.sh b/lib/host/serve.sh index f4604a30..ac54c8c9 100755 --- a/lib/host/serve.sh +++ b/lib/host/serve.sh @@ -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" diff --git a/lib/host/tests/blog.sx b/lib/host/tests/blog.sx index 12963d70..459a729c 100644 --- a/lib/host/tests/blog.sx +++ b/lib/host/tests/blog.sx @@ -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! diff --git a/lib/host/tests/flows.sx b/lib/host/tests/flows.sx new file mode 100644 index 00000000..e6761ad4 --- /dev/null +++ b/lib/host/tests/flows.sx @@ -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})) diff --git a/plans/business-logic-fed-flows.md b/plans/business-logic-fed-flows.md index 8c1e7295..a47b4a2b 100644 --- a/plans/business-logic-fed-flows.md +++ b/plans/business-logic-fed-flows.md @@ -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