From 9ac6a8afd58f0ffae93fba25cbb1d9a7c988f6e7 Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 2 Jul 2026 14:56:00 +0000 Subject: [PATCH] host P0.3: wire the seam into the live publish path (LIVE-VERIFIED) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Publishing a post now fires the on-publish behavior DAG through the seam. host/blog--{transport (activity log), triggers (on-publish: create+article → publish-DAG), driver (records each effect in the flow log), publish-engine (behavior/make-engine over the four adapters + the execute-fold runner + publish-ctx), fire-publish!, maybe-publish!}. Both write handlers (form-submit POST /new, edit-submit POST /:slug/edit) detect the draft→published TRANSITION (fire-once) in the handler body and run behavior/process. GET /flows renders the flow log (the effect-as-data the driver dispatched). LIVE PROOF: logged in + POST /new on blog.rose-ash.com → /flows shows 'validate' + 'notify' (the publish-DAG branched on the default urgent category), driven end-to-end by the real behavior engine. Every piece is a seam adapter — swapping the runner for Erlang (RA) or the transport for fed-sx (TA) federates this same wiring unchanged. blog 207/207 (+4 P0.3), full host conformance 595/595. GAP: flow log is in-memory (P0.3b = persist). Co-Authored-By: Claude Opus 4.8 --- lib/host/blog.sx | 69 +++++++++++++++++++++++++++++-- lib/host/tests/blog.sx | 25 +++++++++++ plans/business-logic-fed-flows.md | 21 +++++++--- 3 files changed, 107 insertions(+), 8 deletions(-) diff --git a/lib/host/blog.sx b/lib/host/blog.sx index d040d130..5c144574 100644 --- a/lib/host/blog.sx +++ b/lib/host/blog.sx @@ -145,6 +145,43 @@ (define host/blog--publish-ctx (fn (activity) (let ((o (get activity :object))) {"category" (get o :category) "slug" (get o :slug)}))) +;; ── P0.3: the seam WIRED on the live host ────────────────────────────── +;; The publish ENGINE = the execute-fold runner (flows.sx) + a local-SX on-publish trigger registry +;; + an in-process transport (the activity log = the event source) + the host driver (records each +;; effect in the flow log). Publishing a post (draft→published, fire-once) builds the activity and +;; runs it through behavior/process → the DAG's effects surface on /flows. In-memory logs for P0 +;; (durable-store backing is the follow-up). Every piece is a seam adapter — swap the runner for +;; Erlang (RA) or the transport for fed-sx (TA) and this same wiring federates, unchanged. +(define host/blog--activity-log (list)) ;; every publish activity emitted (the event source) +(define host/blog--flow-log (list)) ;; what the flows DID (the driver's effect records) +(define host/blog--transport + {:emit (fn (a) (set! host/blog--activity-log (concat host/blog--activity-log (list a)))) + :deliver (fn () (list))}) ;; nothing inbound yet — P0 is synchronous +(define host/blog--triggers + {:register! (fn (spec dag hint) nil) + :match (fn (a) (if (and (= (get a :type) "create") (= (get (get a :object) :type) "article")) + (list {:dag host/blog--publish-dag}) (list)))}) +(define host/blog--driver + {:dispatch (fn (eff) + (begin (set! host/blog--flow-log + (concat host/blog--flow-log + (list {:verb (get eff :verb) :args (get eff :args)}))) + (list)))}) ;; record the effect; no follow-up activities (P0) +(define host/blog--publish-engine + (behavior/make-engine {:triggers host/blog--triggers :runner host/flow--exec-runner + :transport host/blog--transport :driver host/blog--driver + :ctx-of host/blog--publish-ctx})) +;; fire the publish flow for a slug: build the activity, run it through the seam. Returns the trace. +(define host/blog--fire-publish! + (fn (slug) + (let ((a (host/blog--publish-activity slug))) + (if (nil? a) nil (behavior/process host/blog--publish-engine a))))) +;; the draft→published TRANSITION (fire-once): only a non-published → published shift fires the flow. +(define host/blog--maybe-publish! + (fn (slug prev-status new-status) + (if (and (not (= prev-status "published")) (= new-status "published")) + (host/blog--fire-publish! slug) nil))) + ;; ── render ────────────────────────────────────────────────────────── ;; A post's sx_content is SX element markup -> HTML via render-page (which supplies ;; the server env so components resolve + keyword attrs are kept). @@ -2376,9 +2413,12 @@ (p (a :href "/new" "Back"))))))) (else (let ((slug (host/blog-slugify title))) - (begin - (host/blog-put! slug title (or sx-content "") status) - (dream-redirect (str "/" slug "/"))))))))) + (let ((prev (host/blog-get slug))) + (begin + (host/blog-put! slug title (or sx-content "") status) + ;; P0.3: a draft→published transition fires the publish flow through the seam. + (host/blog--maybe-publish! slug (if prev (get prev :status) nil) status) + (dream-redirect (str "/" slug "/")))))))))) ;; The JSON CRUD /posts (create/update/delete) was DELETED in the greenfield ;; SX-native pivot (plans/relations-as-posts.md, "SX all the way out") — it was a @@ -2593,6 +2633,8 @@ (if (= (len issues) 0) (begin (host/blog-put! slug title sx-content status) + ;; P0.3: a draft→published transition fires the publish flow through the seam. + (host/blog--maybe-publish! slug (get r :status) status) ;; store the typed field values from the generic, type-driven form (Slice 8b) (host/blog--set-field-values! slug (reduce (fn (acc f) @@ -2609,6 +2651,26 @@ (unquote (cons (quote ul) issue-items)) (p (a :href (unquote (str "/" slug "/edit")) "Back")))))))))))))) +;; ── /flows — P0.3 acceptance surface ───────────────────────────────── +;; What the publish workflows DID: each effect-as-data record the host driver dispatched, produced +;; by running the on-publish DAG through the seam. Publishing a post appends here (live proof the +;; behavior engine fired). Public read. +(define host/blog-flows + (fn (req) + (host/blog--resp req 200 + (host/blog--page req "Flows" + (quasiquote + (div (h1 "Flows") + (p "Effect-as-data from publish workflows — the seam: on-publish → publish-DAG → effects.") + (unquote + (if (= (len host/blog--flow-log) 0) + (quote (p (em "No flows yet — publish a post to fire the on-publish DAG."))) + (cons (quote ul) + (map (fn (e) + (quasiquote (li (strong (unquote (get e :verb))) " " + (unquote (if (> (len (get e :args)) 0) (str (first (get e :args))) ""))))) + host/blog--flow-log)))))))))) + ;; ── routes ────────────────────────────────────────────────────────── ;; Public reads + the create form. /, /posts, /new BEFORE /:slug (catch-all). ;; MUST be mounted LAST in the app so domain routes (/feed, /health) win. @@ -2620,6 +2682,7 @@ (dream-get "/tags" host/blog-tags-index) (dream-get "/meta" host/blog-meta-index) (dream-get "/workflow-demo" host/blog-workflow-demo) + (dream-get "/flows" host/blog-flows) (dream-get "/:slug/source" host/blog-source) (dream-get "/:slug/relate-options" host/blog-relate-options) (dream-get "/:slug" host/blog-post))) diff --git a/lib/host/tests/blog.sx b/lib/host/tests/blog.sx index 459a729c..0b83b8d3 100644 --- a/lib/host/tests/blog.sx +++ b/lib/host/tests/blog.sx @@ -1200,6 +1200,31 @@ (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)) +;; P0.3: the draft→published TRANSITION fires the publish flow THROUGH THE SEAM (engine = the +;; execute-fold runner + on-publish registry + transport + host driver) → effects land in the flow log. +(set! host/blog--flow-log (list)) +(set! host/blog--activity-log (list)) +(host-bl-test "P0.3: draft→published fires the publish flow through the seam → effects logged" + (begin + (host/blog-put! "p03a" "P" "(article (h1 \"x\"))" "published") + (host/blog--set-field-values! "p03a" {"category" "newsletter"}) + (host/blog--maybe-publish! "p03a" "draft" "published") + (list (map (fn (e) (get e :verb)) host/blog--flow-log) (len host/blog--activity-log))) + (list (list "validate" "digest") 1)) +(host-bl-test "P0.3: published→published does NOT re-fire (fire-once on the transition)" + (begin + (host/blog--maybe-publish! "p03a" "published" "published") + (list (map (fn (e) (get e :verb)) host/blog--flow-log) (len host/blog--activity-log))) + (list (list "validate" "digest") 1)) +(host-bl-test "P0.3: a →draft transition does not fire" + (begin (host/blog--maybe-publish! "p03a" "published" "draft") (len host/blog--activity-log)) 1) +(host-bl-test "P0.3: a fresh nil→published (new post) fires, urgent→notify" + (begin + (host/blog-put! "p03b" "U" "(article (h1 \"u\"))" "published") + (host/blog--set-field-values! "p03b" {"category" "urgent"}) + (host/blog--maybe-publish! "p03b" nil "published") + (map (fn (e) (get e :verb)) host/blog--flow-log)) + (list "validate" "digest" "validate" "notify")) (define host-bl-tests-run! diff --git a/plans/business-logic-fed-flows.md b/plans/business-logic-fed-flows.md index 86ccbf8d..91455b97 100644 --- a/plans/business-logic-fed-flows.md +++ b/plans/business-logic-fed-flows.md @@ -146,11 +146,16 @@ fed-sx yet — those are adapter phases (RA/TA). Every piece swaps later; the DA memoization / optimize (fuse/dedup/dce) / schedule / FEDERATION (a flow result reused across peers by content-id — the federation vision, free). The capability model makes that migration seamless (same DAGs, richer runner; the execute-fold is just the pragmatic sync runner NOW). See phase AX. -- [ ] **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 - activity, behavior/process it (in the handler BODY, not a render). ACCEPTANCE: publish a post on - the LIVE host → the effect surfaces (a /flows page + a durable record). +- [x] **P0.3 — wire the seam on the live host. DONE + LIVE-VERIFIED 2026-07-02.** host/blog-- + {transport, triggers (on-publish: create+article → publish-DAG), driver (records each effect), + publish-engine, fire-publish!, maybe-publish!}. Both write handlers (form-submit POST /new, + edit-submit POST /:slug/edit) call maybe-publish!(slug, prev-status, new-status) — a non-published + → published TRANSITION fires the flow (fire-once), in the handler BODY. /flows renders the flow + log. LIVE PROOF: logged in + POST /new on blog.rose-ash.com → /flows shows `validate` + `notify` + (category defaulted to urgent). behavior→exec-runner→driver all real. blog 207/207, conformance + 595/595. GAP: the flow log is IN-MEMORY (clears on restart) — "durable record" is P0.3b (persist + the log to the blog store + boot-load, string-keyed to dodge the keyword/persist split). Also: the + live test post `p0.3-seam-live-test` persists (no delete route) — harmless, clean up if wanted. - [ ] **P0.4 — canonical activity + reconcile.** Move host/blog--publish-activity to the seam shape {:verb :actor :object :object-type :delta :ts :id}; keep the category/slug fields the DAG reads. @@ -209,6 +214,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.3 DONE + LIVE-VERIFIED. The seam wired into the live publish path: on-publish + registry + in-process transport + host driver + the execute-fold runner, fired by the draft→ + published transition in both write handlers. Published a real post on blog.rose-ash.com → /flows + surfaced validate + notify, driven by the actual behavior engine. blog 207/207, conformance + 595/595. NEXT: P0.4 (canonical activity shape) or P0.3b (durable flow log); then P1 (types declare + behavior — build the engine per type from its :behavior bindings, runner derived via caps). - 2026-07-02 — DON'T-CALCIFY note (user: "artdag may in the future contain business logic"). The execute-fold-vs-artdag split from P0.2 is a capability SNAPSHOT, not a boundary. Added phase AX: artdag grows +{effect,branch,each} node-kinds and business logic migrates onto it to inherit