diff --git a/lib/host/blog.sx b/lib/host/blog.sx index 4dec2245..761380a4 100644 --- a/lib/host/blog.sx +++ b/lib/host/blog.sx @@ -108,11 +108,13 @@ 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). +;; P0.1/P0.4: the PUBLISH-ACTIVITY contract, in the CANONICAL SEAM SHAPE +;; {:verb :actor :object :object-type :slug :category :delta :id} — :object is a +;; content-addressed REFERENCE (the CID), not an inlined dict; :id is the dedup identity; :slug + +;; :category are the domain fields the publish-DAG's ctx reads. Each runner adapter MARSHALS this +;; to its substrate (host/blog--activity->erl → next/'s proplist for the Erlang runner, RA). +;; category (the DAG's branch: newsletter/urgent/else) comes from the "category" field-value, else +;; the first tag, else "urgent". (define host/blog--post-category (fn (slug) (let ((fc (get (host/blog-field-values-of slug) "category"))) @@ -123,11 +125,24 @@ (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)}})))) + (let ((cid (host/blog-cid slug))) + {:verb "create" ;; the transition (draft→published = create) + :actor "site" ;; P0: a fixed site actor; per-author later + :object cid ;; content-addressed REFERENCE (the CID) + :object-type "article" + :slug slug ;; the handle the publish-DAG's ctx reads + :category (host/blog--post-category slug) ;; the field the DAG branches on + :delta "published" ;; what changed + :id cid}))))) ;; dedup identity (the object CID) +;; MARSHAL the canonical activity → next/'s Erlang proplist shape, for the Erlang runner adapter +;; (RA). The seam activity is canonical; each runner adapter maps it to its substrate. Unused until +;; RA, defined + tested here so the reconcile is complete and RA has its bridge ready. +(define host/blog--activity->erl + (fn (a) + {:type (get a :verb) + :actor (get a :actor) + :id (get a :id) + :object {:type (get a :object-type) :slug (get a :slug) :category (get a :category)}})) ;; 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. @@ -142,8 +157,9 @@ (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). +;; Reads the canonical activity's top-level :category + :slug (P0.4). (define host/blog--publish-ctx - (fn (activity) (let ((o (get activity :object))) {"category" (get o :category) "slug" (get o :slug)}))) + (fn (activity) {"category" (get activity :category) "slug" (get activity :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 @@ -159,7 +175,7 @@ :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")) + :match (fn (a) (if (and (= (get a :verb) "create") (= (get a :object-type) "article")) (list {:dag host/blog--publish-dag}) (list)))}) ;; P0.3b: the flow log is DURABLE — string-keyed records (dodge the keyword/persist top-level split), ;; persisted to the blog store under one key, so /flows survives a restart. Boot-loaded via diff --git a/lib/host/tests/blog.sx b/lib/host/tests/blog.sx index 3f9deaac..21cfd980 100644 --- a/lib/host/tests/blog.sx +++ b/lib/host/tests/blog.sx @@ -1163,25 +1163,34 @@ ;; ── 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" +(host-bl-test "P0.4: publish-activity is the CANONICAL seam shape (:verb :object=cid :object-type :slug :category :id)" (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)) + (list (get a :verb) (get a :object-type) (get a :category) (get a :slug) + (= (get a :object) (get a :id)) (not (nil? (get a :id)))))) + (list "create" "article" "newsletter" "pub1" true 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 (get (host/blog--publish-activity "pub2") :category) + (get (host/blog--publish-activity "pub3") :category))) (list "urgent" "urgent")) (host-bl-test "publish-activity of a missing post is nil" (host/blog--publish-activity "nope-nope-nope") nil) +;; P0.4: the marshaller maps the canonical shape → next/'s Erlang proplist (for the RA runner). +(host-bl-test "activity->erl marshals canonical → next/ proplist ({type,object:{type,slug,category}})" + (begin + (host/blog-put! "pub4" "P4" "(article (h1 \"m\"))" "published") + (host/blog--set-field-values! "pub4" {"category" "newsletter"}) + (let ((e (host/blog--activity->erl (host/blog--publish-activity "pub4")))) + (list (get e :type) (get (get e :object) :type) + (get (get e :object) :slug) (get (get e :object) :category)))) + (list "create" "article" "pub4" "newsletter")) ;; 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" diff --git a/plans/business-logic-fed-flows.md b/plans/business-logic-fed-flows.md index 5758ec70..02ba5c74 100644 --- a/plans/business-logic-fed-flows.md +++ b/plans/business-logic-fed-flows.md @@ -63,9 +63,8 @@ object's state changes emit activities; the platform picks runner/transport/plac · **behavior bindings** (triggers → DAG). All composition, all editable in the type-def editor. - **CANONICAL ACTIVITY = the seam shape** `{:verb :actor :object :object-type :delta :prev :ts :id}`; each runner adapter MARSHALS it to its substrate (e.g. the Erlang runner → next/'s - `[{type,…},{object,[…]}]` proplist). NOTE: P0.1's host/blog--publish-activity currently emits the - next/-Erlang shape ({:type :object-dict}); P0.4 reconciles it to the canonical seam shape + a - marshaller. + `[{type,…},{object,[…]}]` proplist via host/blog--activity->erl). DONE (P0.4): host/blog--publish- + activity emits the canonical shape; host/blog--activity->erl is the RA marshaller (staged). **Reference (one durable-runner substrate, verified):** `next/tests/triggers_e2e.sh` = 10/10 — next/'s trigger_registry + flow_dispatch + blog_publish_digest (suspend/resume/guard/dedup). This @@ -156,8 +155,20 @@ fed-sx yet — those are adapter phases (RA/TA). Every piece swaps later; the DA 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. +- [x] **P0.4 — canonical activity + reconcile. DONE + LIVE-VERIFIED 2026-07-02.** host/blog-- + publish-activity now emits the CANONICAL seam shape {:verb :actor :object :object-type :slug + :category :delta :id} — :object is a content-addressed REFERENCE (the CID, was an inlined dict), + :id the dedup identity, :slug+:category the domain fields the DAG reads. Consumers reconciled: the + trigger matches :verb+:object-type; publish-ctx reads top-level :category+:slug. Added the runner + MARSHALLER host/blog--activity->erl (canonical → next/'s proplist, for RA — defined+tested, unused + until RA). (:ts/:prev omitted — no clock primitive in the host; deferred.) LIVE: published on + blog.rose-ash.com → /flows fired validate+notify with the canonical activity. blog 209/209, + conformance 597/597. + +**P0 COMPLETE** — the synchronous publish workflow runs end-to-end on the LIVE host through the +substrate-agnostic seam, durably, in the canonical activity shape, with the Erlang-runner marshaller +staged. Every piece is a swappable adapter: RA (Erlang runner) + TA (fed-sx transport) plug in next +without touching the DAG or the wiring. ## P1 — types DECLARE behavior (generalize) - [ ] The type carries :behavior = [{:on {:verb :object-type :guard} :dag }] — edited in the @@ -214,6 +225,13 @@ 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.4 DONE + LIVE-VERIFIED → **P0 COMPLETE**. Canonical seam activity shape + ({:verb :object=cid :object-type :slug :category :delta :id}); consumers reconciled (trigger match, + publish-ctx); host/blog--activity->erl marshaller staged for RA. Published live → /flows fired + validate+notify with the canonical activity. blog 209/209, conformance 597/597. The synchronous + publish workflow is end-to-end on the live host through the substrate-agnostic seam, durable, in + the canonical shape. NEXT: P1 (types declare :behavior, engine built per type, runner derived via + caps) — or RA (Erlang durable runner, the marshaller is ready). - 2026-07-02 — P0.3b DONE + LIVE-VERIFIED. The flow log is now DURABLE: the driver persists string-keyed effect records to the blog store (dodging the keyword/persist top-level split); host/blog-load-flowlog! rebuilds it on boot (serve.sh). Proof: published on