Files
rose-ash/plans/business-logic-fed-flows.md
giles 9d29295820 host P1: types DECLARE behavior, runner DERIVED (LIVE-VERIFIED)
Generalizes the hardcoded publish trigger into declared, capability-routed behavior.

- Types carry :behavior — flat string-keyed bindings {"verb" "type" "dag"} on the type-post
  (persist-safe, like :type-relations). The "article" type declares on-create → the "publish" DAG.
- host/blog--load-behaviors! gathers ALL posts' declarations into a registry at boot (serve.sh); the
  trigger match (host/blog--triggers :match = host/blog--match-behaviors) consults it. Hardcoded
  create+article trigger removed.
- Runner DERIVED (DEBT #2 fixed): match resolves :dag via host/blog--dag-registry and picks the
  runner via host/flow--select-runner over host/blog--runner-fleet ([exec-runner]; RA joins at
  RA-live). Each binding carries its :runner; behavior/-run-binding now uses the binding's runner
  (else the engine default) — so the capability model drives the LIVE engine.
- The type-def view shows each behavior + its derived runner (host/blog--behavior-lines).

LIVE PROOF: /article shows 'on create → publish DAG · needs {effect, branch} · runner: synchronous
(exec-fold)'; publishing on blog.rose-ash.com fired /flows validate+notify via the DECLARED path.
blog 213/213 (+3 P1), full host conformance 610/610. FINDING: load-behaviors! scans all posts, not
is-type?-filtered (article failed is-type? on the durable store though it passed in-memory).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 16:34:39 +00:00

32 KiB

Business logic as composition — a content-addressed DAG over pluggable substrates

Vision (elevated 2026-07-02): business logic IS art-dag. An object's behavior is a content-addressed DAG (lib/artdag), declared on its type alongside content grammar + allowed relations. Everything else is a pluggable ADAPTER — the same fold/adapter principle as render-vs-execute-vs-deps, applied to execution/communication/deployment:

  • Behavior = an artdag DAG — the invariant, content-addressed (artdag/dag, analyze/plan/ optimize/schedule). Business logic, art media pipelines, workflows — all the same abstraction.
  • Execution = an injected RUNNER (artdag/run dag RUNNER cache; artdag/op-table-runner). Substrates are runners on a capability ladder, same DAG throughout — a node DECLARES the capabilities it needs, a runner ADVERTISES what it supports, and the match is checked at bind time (fail fast, not a mystery at run time). artdag/analyze computes a DAG's required-capability set → its MINIMUM runner. So "simple in SX / durable in Erlang / distributed in celery-sx" is a DERIVED property of the DAG, not a human judgment; a trivial rule is a one-node DAG needing only {effect} and runs synchronously with zero ceremony. Business logic and media pipelines are the SAME structure (a content-addressed op-DAG); they differ only in the capabilities their nodes require, hence the runner. The ladder (capabilities each runner adds):
    • op-table / execute-fold runner{effect, branch, each} — synchronous, local, in-request. Covers P0.
    • Erlang runner+ suspend/resume/wait (durable), deterministic replay (flow-on-erlang).
    • celery-sx runner+ parallel, retry, offload — distributed/durable task executor, "Celery the way it should have been" on erlang-on-sx, ZERO packages. It's a LEAN GLUE of parts we already have, not a reimplementation: broker = lib/persist KV (durable enqueue/claim/ack/visibility-timeout) · worker pool = the er-scheduler / Erlang processes · result backend = content-addressed results (artdag keys by content-id → dedup/memoization FREE — Celery bolts this on badly) · retries/ replay = flow-on-erlang · scheduling/fan-out/chords = artdag/schedule (minikanren CLP(FD)) + the DAG's topo batches · the plug point = artdag/op-table-runner. The genuinely-new code is small (~a few hundred lines): a durable queue + a worker loop (pull node → runner → write content-addressed result) + retry/backoff. BUILD WHEN A DAG DEMANDS IT — heavy compute, long-running/retryable tasks, or fan-out across machines — NOT for the synchronous P0.
    • real-Celery over artdag/L1+ gpu/heavy-compute — the existing Python media pipeline (JAX/IPFS) as a runner.
  • Communication = an injected TRANSPORT (artdag/federation, transport injected). Substrates: fed-sx (ActivityPub/next/), internal HMAC HTTP (services), IPFS (content-addressed). Because content-ids are global, a result computed on one instance is reusable on another by id.
  • Deployment = PLACEMENT — a subdomain service, a fed-sx peer, an L1 worker: just where a runner runs. Not the essence.
  • State change → triggers a DAG (over a transport) → executed by a runner → effects (data) a driver dispatches. fed-sx + Erlang is ONE adapter set (durable/federated), not THE architecture.

So: the TYPE carries content-grammar + allowed-relations + a behavior DAG (+ triggers); the object's state changes emit activities; the platform picks runner/transport/placement per context.

Design (decided 2026-07-02; corrected after review):

  • Activity log = every OBSERVABLE object-level state change — the event source. NOT just CID deltas: relations write edge:* rows, NOT the record, so a relation change does NOT shift the CID. Two ActivityPub-faithful event classes: content/status change → a CID-carrying Create/Update; relation change → an Add/Remove referencing the edge.
  • Verbs are TRANSITIONS, not raw deltas. on-publish = draft→published (fire-once), not every CID delta of a published post. Emitter picks the verb (Create/Update/Add/Remove/Delete). Global fire-once is the emitter's job (+ a durable inbox for federation); the seam only dedups in-call.
  • Triggers = declared subscriptions — a type declares named triggers; flows fire only on matching ones. Log complete, execution precise.
  • Flows split by CAPABILITY (DERIVED, not a human "complexity" call): every flow is a content-addressed op-DAG. A node declares the capabilities it needs (a wait needs suspend, a fan-out needs parallel, a heavy op needs offload); a runner advertises what it supports; artdag/analyze computes the DAG's required set → its MINIMUM runner; bind-time checks required ⊆ runner (fail fast). So the sync/durable/distributed choice falls out of the DAG — a {effect}-only DAG runs synchronously (no ceremony); a DAG with a wait node auto-requires the Erlang runner. Business logic and media pipelines are the SAME structure — they differ only in node capabilities.
  • Effects are DATA; a DRIVER dispatches them (perform no IO — a blocking call deadlocks the er-scheduler). The host is the driver for P0.
  • The type carries its whole contract: fields+grammar (content) · allowed relations (external) · behavior bindings (triggers → DAG). All composition, all editable in the type-def editor.
  • CANONICAL ACTIVITY = the seam shape {:verb :actor :object <cid> :object-type :delta :prev :ts :id}; each runner adapter MARSHALS it to its substrate (e.g. the Erlang runner → next/'s [{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 is what the ERLANG RUNNER ADAPTER wraps (Phase RA), not what P0 wires directly.

The ADAPTER SEAM (design first — the contract every substrate plugs into)

The invariant is an activity (state-change event) + a behavior DAG. Everything between is a swappable adapter — each a dict-of-functions (SX-native, like the fold domains). Six contracts:

  1. Activity (content-addressed event): {:verb "create"|"update"|"add"|"remove"|"delete" :actor :object <cid> :object-type :delta :prev <cid> :ts}.
  2. Behavior binding (declared on a TYPE): {:on {:verb :object-type :guard} :dag <ref>}. The TYPE carries content-grammar + allowed-relations + these bindings. NO :runner hint — the runner is DERIVED: the DAG's required-capability set (artdag/analyze over its nodes; a node declares :needs e.g. #{suspend}) selects the minimum runner that advertises them.
  3. Trigger-registry adapter{:register! (fn spec dag hint) :match (fn activity -> [binding])}. Impls: a local SX matcher / next/'s Erlang trigger_registry.
  4. Runner adapter{:capabilities <set> :run (fn dag env -> {:status "done"|"suspended"| "failed" :effects :resume :error})}. :capabilities is what it ADVERTISES (op-table {effect, branch, each}; Erlang + suspend; celery-sx + parallel, retry, offload); the binder checks dag-required ⊆ runner-caps and fails fast otherwise. env = {:activity :actor :ctx :effects :binding} (:effects = injected external-read interfaces, deterministic for replay). :results is runner-INTERNAL. Durability = the runner's, not the DAG's. A durable runner that SUSPENDS is wired at construction to the transport's INBOUND channel and injects its out-of-band completion there (pump drains it).
  5. Transport adapter{:emit (fn activity) :deliver (fn -> [activity])}. :emit = outbound (log/publish), :deliver = inbound (peers + async runner completions). Impls: in-process / fed-sx (next/) / HTTP / IPFS. Content-ids global → results move by id.
  6. Effect driver{:dispatch (fn effect -> [activity])}. Perform the effect-as-data; may emit NEW activities (loop closure).

Engine (behavior/make-engine {:triggers :runner :transport :driver :effects? :ctx-of?}) → behavior/process(engine, activity) / behavior/pump(engine):

step(activity):  if activity.id already :seen → skip (dedup)      ; in-call cycle guard
  emit(transport, activity)                                       ; log
  for b in match(triggers, activity):
     r = run(runner, b.dag, {:activity :actor :ctx :effects :binding})
     case r.status:
       "done"      → for eff in r.effects: for a in dispatch(driver, eff): step(a)   ; loop closes
       "suspended" → record (+ r.resume); a durable runner completes out-of-band → inbound → pump
       "failed"    → record (+ r.error) for flow-level retry / dead-letter
pump():  for a in transport.deliver(): step(a)                    ; inbound drain, shared :seen

Every stage injected → swap runner (sync→Erlang→celery-sx), transport (in-proc→fed-sx), registry, driver — DAG + engine unchanged. Global idempotency = emitter fire-once + a durable inbox.

Build order: (a) DONE — seam as SX contracts + reference engine, tested substrate-agnostic: :status branch (done/suspended/failed), injected env (:ctx + :effects), dedup by activity :id, behavior/pump for inbound, async-completion loop (suspend → out-of-band inject → pump). behavior 10/10. (b) P0 supplies the REAL SYNCHRONOUS adapters; durable/federated runners+transports are later adapter phases (RA/TA below).

P0 — publish workflow on the seam (SYNCHRONOUS, all-SX)

Prove: live host publish → the seam engine (in-process transport · local-SX trigger registry · sync op-table runner over an SX publish-DAG · host driver) → the effect surfaces. NO Erlang, NO fed-sx yet — those are adapter phases (RA/TA). Every piece swaps later; the DAG + engine don't.

  • 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 + 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. CAVEAT (don't calcify this finding): execute-fold-vs-artdag is a CURRENT capability SNAPSHOT, NOT a permanent boundary. artdag MAY GROW control-flow node-kinds (a runner advertising +{effect, branch, each}), and business logic then MIGRATES to artdag to inherit content-addressed 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. 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. 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.

P0 REVIEW (2026-07-02) — findings + carried-forward debt

  • FIXED (was a live bug): edit-submit ran maybe-publish! BEFORE set-field-values!, so an edit that set a category AND published branched on the STALE category. Reordered (fields first); regression test added. blog 210/210.
  • DEBT #1 (blocks P2): activity identity. Dedup uses :id = the object CID. Relation Add/Remove events don't change the CID → multiple relation activities would share :id → false dedup. P2 needs a real activity id (verb+object+seq/edge), NOT the bare CID.
  • DEBT #2 (P1): the capability bind isn't wired into the live engine. host/blog--publish-engine calls exec-runner directly; host/flow--bind is tested but decorative in the live path. P1's engine must DERIVE the runner via bind (required ⊆ advertised), not hardcode it.
  • DEBT #3 (RA): flow execution is synchronous in the request path. Fine for 2 cheap effects, but a durable/suspend runner CANNOT block a request — RA needs dispatch moved OFF the request path (emit in-request → a background loop calls behavior/pump). The seam supports it (pump); the wiring doesn't exist. This is structural, not just the marshaller.
  • DEBT #4 (minor): the "urgent" category default notifies everyone for an uncategorised post — demo-convenient, semantically wrong. Default to skip/none once the demo value is past.
  • Acknowledged/known: unbounded flow log (cap/rotate), registry :register! is a stub (P1 makes it real), :actor "site" placeholder (TA needs the actor model), marshaller unverified-until-RA.

Sequencing note (hidden prerequisites)

  • P1's "runner derived via caps" is near-vacuous until a SECOND runner exists — with only exec-runner, everything derives to it. The derivation only MEANS something once RA lands.
  • RA is where the real risk lives (SX→erlang-on-sx dispatch + suspend→resume→pump + the er-scheduler context bug) AND P1/P2 quietly depend on what RA forces (a 2nd runner, the async boundary). RECOMMENDATION: a narrow RA SPIKE next — prove one dispatch + one suspend/resume/ pump cycle in isolation — de-risks the whole durable/federated half before building P1/P2 on it.

P1 — types DECLARE behavior (generalize) — DONE + LIVE-VERIFIED 2026-07-02

  • The type carries :behavior — a list of flat string-keyed bindings {"verb" "type" "dag"} (persist-safe, like :type-relations), stored on the type-post (host/blog--type-behavior / set-type-behavior!). The "article" type declares {"verb" "create" "type" "article" "dag" "publish"}.
  • The behavior REGISTRY (host/blog--behaviors) is gathered at boot from ALL posts' declarations (host/blog--load-behaviors!, in serve.sh after seed-types!); the trigger match (host/blog--triggers :match = host/blog--match-behaviors) consults it. The hardcoded create+article trigger is GONE.
  • The runner is DERIVED, not hinted (DEBT #2 fixed): match-behaviors resolves the :dag via a DAG registry (host/blog--dag-registry) and picks the runner via host/flow--select-runner over the fleet (host/blog--runner-fleet = [exec-runner]; RA joins at RA-live). Each binding carries its :runner; behavior/-run-binding uses it. An {effect,branch} publish-DAG → exec-runner; a {suspend} DAG would route to RA (proven in ra 9/9 with a 2-runner fleet).
  • The type-def view SHOWS each behavior + its derived runner (host/blog--behavior-lines): LIVE at blog.rose-ash.com/article — "on create → publish DAG · needs {effect, branch} · runner: synchronous (exec-fold)". Derived + visible, not hand-set.
  • LIVE PROOF: published on blog.rose-ash.com → /flows fired validate+notify via the DECLARED path (registry + derived runner). blog 213/213, full conformance 610/610. FINDING: load-behaviors! must scan ALL posts, not filter by is-type? (article didn't pass is-type? on the durable store though it did in-memory) — the type declaration is authoritative, the is-type? classification isn't reliable enough.

P2 — state-change → activity emission (ALL events, not just publish)

  • Wire the host write path: put!/set-comp!/edit-submit → Create/Update; relate!/unrelate!/tag → Add/Remove. Emit canonical activities into the transport log. Define the delta summary.

RA — the ERLANG (durable) RUNNER adapter ← the old "fed-sx spike", now an adapter

SPIKE DONE 2026-07-02 — RA is VIABLE (plans/ra-spike.sh, 4/4). Proven from the SX side: (1) our canonical activity dict → Erlang activity-proplist SOURCE (ra/erl-src ≈ host/blog--activity->erl) is valid Erlang the flow consumes; (2) it drives pipeline:apply_triggers → blog_publish_digest → done

  • 3 emails (urgent sync branch); (3) the newsletter activity SUSPENDS on the morning timer (status =:= {ok,{suspended,morning}}); (4) flow_store:resume(Id, morning_ts) COMPLETES it → 3 emails (the async cycle); (5) NO er-scheduler deadlock — flow-on-erlang's railway threading holds when driven from SX. KEY FINDINGS for the build: erlang-eval-ast returns Erlang TERMS directly (integers raw, atoms as {:tag atom :name …}) — the runner must parse results, not assume :name; flow_store start→{done,V}|{suspended,Tag}, resume(Id,Res) maps 1:1 onto {:status done|suspended :effects :resume}; the flow instance Id is the resume handle.
  • RA RUNNER BUILT + TESTED (module + integration) 2026-07-02. lib/host/ra.sx — a PURE-SX seam runner (advertises {effect,branch,each,suspend}) with an INJECTED erl-eval (real = er-to-sx-deep ∘ erlang-eval-ast; mock in unit tests), so it loads in the plain host and is testable without the Erlang runtime. host/ra--{atom,bin,erl-src,start-expr,resume-expr,parse,make-runner, resume,real-eval}: marshals our canonical activity → Erlang source (CID as <<"…">> binary, atoms single-quoted), starts flow_store, parses (ok Id (flow_done V))→{:status done :effects V :flow-id} / (ok Id (flow_suspended T))→{:status suspended :resume {:id :tag}}. DUAL-RUNNER ROUTING in flows.sx: host/flow--required-caps handles a {:erl-flow :needs} DAG (declared caps); host/flow--select-runner picks the cheapest runner covering the DAG's needs — the capability model is now REAL (2 runners: an {effect,branch} composition → exec-runner; a {suspend} DAG → RA). ra 9/9 (mock) + plans/ ra-integration.sh 4/4 (the REAL module driving live flow_store: urgent→done, newsletter→suspended with resume handle, effect-as-data carried). Full host conformance green. next/tests/triggers_e2e.sh 10/10 baseline intact.
  • RA-LIVE (deferred — the deployment step, prerequisite now PRECISE). KEY FINDING: gen_servers do NOT persist across separate erlang-eval-ast calls (flow README: "the scheduler doesn't preserve spawned processes across separate erlang-eval-ast invocations"). So a boot-per-call proves the module (done), but TRUE async (suspend → return the request → resume LATER in another call) needs a PERSISTENT next/ kernel PROCESS holding flow_store — the async boundary (DEBT #3) is deeper than "off the request path". REMAINING: (a) stand up a long-lived next/ kernel (nx_kernel/http_server already run persistently for TCP) that RA talks to; (b) wire a DURABLE behavior binding ({:erl-flow "blog_digest" :needs (effect branch suspend)}) into the live publish engine, routed to RA via select-runner; (c) the resumed completion re-enters via the transport inbound + behavior/pump. The runner + marshalling + suspend/resume mechanics are all proven; this is process lifecycle + wiring.

TA — the FED-SX TRANSPORT adapter ← federation proper

  • A seam transport over next/ delivery: :emit → outbox → peers; :deliver → inbox. A remote peer's engine fires its own triggers. Everything works over fed-sx. RISK: next/ delivery M2 blockers (er-scheduler context). Needs the ACTOR MODEL real (:actor was a P0 placeholder — peer_actors / follower_graph / per-author identity).

AX — artdag GROWS control-flow (business logic MIGRATES to artdag) [DEMAND-DRIVEN]

Today artdag is pure dataflow and the execute-fold is the synchronous control-flow runner. That's a capability snapshot. When a business flow WANTS the artdag engine's benefits — content-addressed memoization (recompute only on input-CID change), optimize (fuse/dedup/dce), schedule, and above all FEDERATION (a flow result reused across peers by content-id) — grow artdag's node vocabulary so its runner advertises +{effect, branch, each}, and the SAME behavior DAGs migrate onto it (the capability model makes this seamless; the seam + DAGs don't change). Two real design pieces: (1) DYNAMIC control in a static DAG — a branch PRUNES a path (conditional nodes make downstream nodes live/dead; content-addressing holds on the taken path); (2) EFFECT nodes vs memoization — pure nodes memoize, {effect} nodes are marked non-cacheable (must run / be idempotent). Build when a flow's cost, reuse, or cross-peer sharing makes the execute-fold's re-run-everything insufficient — not before. The execute-fold stays the lean default for cheap synchronous flows.

RX — celery-sx runner (DEMAND-DRIVEN, not scheduled)

Build the distributed/durable runner adapter the moment a real DAG needs heavy compute / long-running-retryable tasks / cross-machine fan-out (the artdag/JAX media case, or federated flows that can't run in-request). New code is small — glue persist (durable queue: enqueue/claim/ ack/visibility-timeout) + er-scheduler (worker loop: pull node → op-table-runner → content- addressed result) + artdag/schedule (fan-out) + retry/backoff. Slots in at artdag/op-table-runner alongside the synchronous + Erlang runners. Zero packages. Do NOT pre-build; the op-table runner covers everything until a DAG's cost/latency/placement forces the substrate.

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 — P1 DONE + LIVE-VERIFIED. Types DECLARE :behavior (stored on the type-post, gathered into a registry at boot); the trigger match consults the registry; the runner is DERIVED via host/flow--select-runner over the fleet (DEBT #2 fixed — no hardcoded trigger, no runner hint). The /article page shows the declared behavior + derived runner. Published live → /flows fired via the declared path. blog 213/213, conformance 610/610. Finding: load-behaviors! scans ALL posts (not is-type?-filtered — unreliable on the durable store). NEXT: RA-live (persistent kernel wires RA into the fleet → durable bindings route to RA), or P2 (all state-change → activity emission).
  • 2026-07-02 — RA RUNNER BUILT + tested (module + integration). lib/host/ra.sx = a pure-SX seam runner with injected erl-eval (loads in the plain host, mock-testable); marshals our activity → Erlang, drives flow_store, parses done/suspended → the runner contract. Dual-runner ROUTING in flows.sx (host/flow--select-runner + required-caps for {:erl-flow :needs} DAGs) makes the capability model REAL (2 runners). ra 9/9 (mock) + plans/ra-integration.sh 4/4 (REAL module → live flow_store). Full host conformance 607/607. FINDING: gen_servers don't persist across erlang-eval-ast calls, so RA-LIVE (true cross-call suspend/resume) needs a persistent next/ kernel process — the async boundary is deeper than "off the request path". Runner mechanics fully proven; RA-live = lifecycle
    • wiring. NEXT: RA-live (persistent kernel + a durable binding wired to RA), or P1 (capability model is now real, so it's no longer vacuous).
  • 2026-07-02 — RA SPIKE DONE → RA is VIABLE (plans/ra-spike.sh, 4/4). From SX: our canonical activity serializes to valid Erlang, drives blog_publish_digest through flow_store (done + suspend + resume), no er-scheduler deadlock. De-risks the whole durable/federated half. Findings: erlang-eval-ast returns terms directly (parse, don't assume :name); flow_store start/resume maps 1:1 onto the runner contract. Remaining for full RA: load the Erlang runtime into serving (or out-of-process), the async dispatch boundary (DEBT #3), CID→binary marshalling, structured result parsing. NEXT: full RA, or P1 now that a real 2nd runner is proven reachable.
  • 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 blog.rose-ash.com, RESTARTED the container, /flows still showed validate+notify (reloaded from the store). blog 208/208, conformance 596/596. Whole-list rewrite per effect — cap/rotate later.
  • 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 content-addressed memoization / optimize / FEDERATION (flow result reused across peers by CID — the federation vision, free). Design work named: dynamic control (branch prunes) in a static DAG; effect nodes non-cacheable vs pure nodes memoized. Demand-driven; execute-fold stays the lean default. P0.2 finding + flows.sx header annotated so the finding doesn't harden.
  • 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 computes a DAG's min-runner; the binder checks required ⊆ runner-caps (fail fast). So the sync/ durable/distributed split is DERIVED from the DAG, not a human call — a {effect}-only DAG runs with zero ceremony; a wait node auto-requires Erlang. Removed the :runner hint from the binding. P0.2 gains the hypothesis test: does the publish workflow express naturally as a DAG, and does flipping a node to wait fail-fast against the op-table runner? Clarifies "business logic = art-dag" — same op-DAG structure, differing only in node capabilities, hence runner. (Insight: they're two instances of one thing, suitable to run very differently.)
  • 2026-07-02 — whole-plan coherence review. The reframe (artdag+seam) had left the middle stale: P0.2/P0.3 still described the Erlang-bridge-first path; P0.1's activity didn't match the seam contract; the seam section predated the enrichment. Fixed: P0 rewritten around the seam + SX op-table runner (all-SX, no Erlang/fed-sx); the Erlang/fed-sx path DEMOTED to explicit adapter phases RA (durable runner) + TA (fed-sx transport); canonical activity shape + P0.4 reconcile; seam contract refreshed to behavior.sx (status/dedup/pump/async, behavior 10/10); stray bits cleared.
  • 2026-07-02 — seam DONE + reviewed twice. lib/host/behavior.sx (engine + 4 adapters); enriched substrate-agnostic (status/env/dedup/pump); 2nd review corrected the async-completion contract (construction-wired inbound + pump, not env :emit) + proved it. behavior 10/10, conformance 580.
  • 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).