From 3675d059b50e56e722079c6e04d4d6f818ce554e Mon Sep 17 00:00:00 2001 From: giles Date: Thu, 2 Jul 2026 12:55:08 +0000 Subject: [PATCH] host P0.1: publish-activity contract for federated composition-flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Business logic as federated composition-flows (plans/business-logic-fed-flows.md). P0.1: the host describes a published post as a fed-sx activity — host/blog--publish-activity(slug) → {:type "create" :actor "site" :id :object {:type "article" :slug :category}} — the exact shape next/'s trigger machinery consumes (verified: next/tests/triggers_e2e.sh 10/10). category (drives the flow branch: newsletter suspends / urgent fires / else skip) comes from the "category" field-value, else the first tag, else "urgent". + host/blog--post-category. Design decided: activity log = every CID delta (event source); triggers = declared subscriptions (DefineTrigger); flows hybrid (SX composition for simple via the execute-fold, named Erlang flows for complex); federated execution = Erlang (next/); the type carries content+relations+behavior. blog 200/200 (+3: contract, category fallback, missing-post nil). Co-Authored-By: Claude Opus 4.8 --- lib/host/blog.sx | 22 +++++++++++ lib/host/tests/blog.sx | 23 +++++++++++ plans/business-logic-fed-flows.md | 65 +++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 plans/business-logic-fed-flows.md diff --git a/lib/host/blog.sx b/lib/host/blog.sx index adf28cd3..17369cc8 100644 --- a/lib/host/blog.sx +++ b/lib/host/blog.sx @@ -107,6 +107,28 @@ (fn (acc slug) (if acc acc (if (= (host/blog-cid slug) cid) slug acc))) nil (host/blog-slugs)))) +;; ── business logic as federated composition-flows (plans/business-logic-fed-flows.md) ── +;; P0.1: the PUBLISH-ACTIVITY contract. A published post is described as a fed-sx activity — +;; the same shape next/'s trigger machinery consumes ({:type verb :actor :id CID :object …}). +;; The trigger (on-publish → a flow, e.g. blog_publish_digest) fires on a matching activity. +;; category (drives the flow's branch: newsletter suspends, urgent fires now, else skip) comes +;; from the post's "category" field-value, else its first tag, else "urgent" (fires the demo). +(define host/blog--post-category + (fn (slug) + (let ((fc (get (host/blog-field-values-of slug) "category"))) + (if (and fc (not (= fc ""))) fc + (let ((tags (host/blog-out slug "tagged"))) + (if (> (len tags) 0) (first tags) "urgent")))))) +(define host/blog--publish-activity + (fn (slug) + (let ((r (host/blog-get slug))) + (if (nil? r) nil + {:type "create" ;; publishing = the article enters the fed world + :actor "site" ;; P0: a fixed site actor; per-author later + :id (host/blog-cid slug) ;; the object's content CID + :object {:type "article" :slug slug + :category (host/blog--post-category slug)}})))) + ;; ── 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). diff --git a/lib/host/tests/blog.sx b/lib/host/tests/blog.sx index 99448724..12963d70 100644 --- a/lib/host/tests/blog.sx +++ b/lib/host/tests/blog.sx @@ -1160,6 +1160,29 @@ (host-bl-test "by-cid of an unknown CID is nil" (host/blog-by-cid "znope-nope") nil) +;; ── P0.1: business-logic fed-flows — the publish-activity contract ── +;; a published post is described as a fed-sx create activity that next/'s trigger machinery +;; consumes; category (drives the flow branch) comes from a field-value, else a tag, else urgent. +(host-bl-test "publish-activity describes a published post as a fed-sx create activity" + (begin + (host/blog-put! "pub1" "Pub One" "(article (h1 \"P\"))" "published") + (host/blog--set-field-values! "pub1" {"category" "newsletter"}) + (let ((a (host/blog--publish-activity "pub1"))) + (list (get a :type) (get (get a :object) :type) (get (get a :object) :category) + (get (get a :object) :slug) (not (nil? (get a :id)))))) + (list "create" "article" "newsletter" "pub1" true)) +(host-bl-test "publish-activity category falls back to a tag, else urgent" + (begin + (host/blog-put! "pub2" "Pub Two" "(article (h1 \"Q\"))" "published") + (host/blog-seed! "urgent" "Urgent" "(article (h1 \"u\"))" "published") + (host/blog-relate! "pub2" "urgent" "tagged") + (host/blog-put! "pub3" "Pub Three" "(article (h1 \"R\"))" "published") ;; no field, no tag + (list (get (get (host/blog--publish-activity "pub2") :object) :category) + (get (get (host/blog--publish-activity "pub3") :object) :category))) + (list "urgent" "urgent")) +(host-bl-test "publish-activity of a missing post is nil" + (host/blog--publish-activity "nope-nope-nope") nil) + (define host-bl-tests-run! (fn () diff --git a/plans/business-logic-fed-flows.md b/plans/business-logic-fed-flows.md new file mode 100644 index 00000000..2f14e85f --- /dev/null +++ b/plans/business-logic-fed-flows.md @@ -0,0 +1,65 @@ +# Business logic as federated composition-flows + +**Vision:** business logic is part of an object's composition — declared on its **type**, +alongside content grammar + allowed relations. State changes federate over fed-sx; federated +services execute the business logic as durable **flows**. + +**Design (decided 2026-07-02):** +- **Activity log = every CID delta** — the federated event source of truth. Any content change + (new CID, per host/blog--cid-of: content in the record → CID; relations are external → not). +- **Triggers = declared subscriptions** — a type declares named triggers (on-publish, on-relate, + …); flows fire only on matching ones. (fed-sx `DefineTrigger`: activity-type → flow-name + guard + + actor-scope.) Log complete, execution precise. +- **Flows = hybrid** — simple logic authored as **SX composition** (the execute-fold: effect/alt/ + each — already live in /workflow-demo); an escape hatch to named **Erlang flows** + (next/flow/*.erl, effect-as-data, deterministic replay, suspend/resume) for the complex. +- **Federated execution = Erlang** (`next/` fed-sx Milestone-1 kernel: trigger_registry + + flow_dispatch + pipeline post-append fan-out). Authoring stays SX; the fed-sx activity is the + bridge. flow-on-sx (Scheme, lib/flow) remains for purely-local durable logic. +- **The type carries its whole contract:** fields+grammar (content) · allowed relations + (external) · triggers+flows (behavior). All composition, all editable in the type-def editor. + +**Verified baseline:** `next/tests/triggers_e2e.sh` = 10/10 — publish activity → trigger → +blog_publish_digest flow (urgent/newsletter-suspend/draft-skip/guard-reject/dedup). This is the +reference P0 wires the live host onto. + +## P0 — publish workflow, end-to-end (spike) +Prove: live host publishes a post → fed-sx activity → on-publish trigger → blog_publish_digest. + +- [x] **P0.1 — the publish-activity contract (SX side).** host/blog--publish-activity(slug): + a post record → the fed-sx activity {:type "create" :actor "site" :id :object {:type + "article" :category … :slug …}}. category from field-value "category", else first tag, else + "urgent". + host/blog--post-category. blog 200/200 (3 tests). DONE 2026-07-02. +- [ ] **P0.2 — the dispatch bridge.** On publish (host/blog-edit-submit status→published, or a + dedicated publish action), emit the activity into the trigger machinery. RUNTIME CHOICE for P0: + in-process — serve.sh loads next/ kernel + registers the on-publish trigger; host emits via the + erlang-on-sx bridge (erlang-eval-ast pipeline:apply_triggers). (P3 replaces this with real fed-sx + delivery over next/kernel/http_server.erl.) +- [ ] **P0.3 — the effect is visible.** The flow's digest_sent effect surfaces (a durable record / + a DigestSent activity back in the log / shown on the post or a /flows page). Close the local loop. + +## P1 — types declare behavior (generalize) +- [ ] A Composition field / the type carries a :triggers list (on-publish → flow-name + guard) — + edited in the type-def editor, like grammar + relations. +- [ ] A fold turns a type's declared behavior into DefineTrigger + flow registrations at boot. +- [ ] Simple flows authored as SX composition (execute-fold) → compiled/dispatched to the engine; + complex ones reference named Erlang flows. + +## P2 — state-change → activity emission (the CID-delta event source) +- [ ] Every content mutation (new CID) appends a state-change activity to the log. Define the + activity envelope (verb, actor, object CID, prev CID, delta summary). +- [ ] Wire the host's write path (put!/set-comp!/edit-submit) to the append. + +## P3 — federation proper +- [ ] Activities cross peers via next/ delivery (http_server / outbox / follower_graph). A remote + service's trigger_registry fires the flow. Everything works over fed-sx. + +## P4 — close the loop +- [ ] Flow effects mutate objects back durably (a flow's DescribeEffects → host writes / new + activities), so business logic can change state, which federates, which triggers more flows. + +## Progress log (newest first) +- 2026-07-02 — P0.1 done. host/blog--publish-activity + host/blog--post-category; the publish + contract in SX, 200/200. Verified next/ triggers e2e baseline 10/10. Roadmap anchored. NEXT: + P0.2 the dispatch bridge (in-process: serve.sh loads next/ kernel + registers the on-publish + trigger; host emits the activity via the erlang-on-sx bridge to pipeline:apply_triggers).