Files
rose-ash/plans/business-logic-fed-flows.md
giles c147020059 plan: elevate — business logic IS art-dag; substrates are adapters
Reframe after the user's insight, confirmed in code: artdag-on-sx already IS the substrate-
independent behavior engine — artdag/run injects the RUNNER (execution adapter: SX op-table /
Erlang / Celery), federation.sx injects the TRANSPORT (communication adapter: fed-sx / HTTP /
IPFS). Business logic = a content-addressed DAG; durability is a RUNNER capability (same DAG runs
eager or durable); deployment (subdomain service / peer / L1 worker) is placement. fed-sx+Erlang
is ONE adapter set, not the architecture. The type carries content-grammar + allowed-relations +
a behavior DAG. The prior fed-sx/Erlang framing is kept as one concrete first slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 13:31:10 +00:00

116 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 just runners: SX op-table (synchronous/local), Erlang (durable — suspend/resume/
wait), Celery/JAX (heavy compute, artdag/l1), … **Durability is a runner capability, not a DAG
feature** — the same DAG runs eager or durable depending on the 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.
**Prior narrower framing (kept below as the concrete first slice):** wire the live host's publish
onto next/'s Erlang trigger→flow machinery. That's now understood as *one adapter choice* — a good
concrete spike, but P0 should keep the DAG + the state-change event substrate-CLEAN so runners and
transports swap trivially.
**Design (decided 2026-07-02; corrected after review):**
- **Activity log = every OBSERVABLE object-level state change** — the federated event source.
NOT just CID deltas: verified that relations write `edge:*` rows, NOT the record, so a relation
change does NOT shift the CID. So the log has TWO event classes (ActivityPub-faithful):
content/status change → a CID-carrying `Create`/`Update` (the record's canon incl. :status → the
CID); relation change (relate/unrelate/tag) → an `Add`/`Remove` activity referencing the edge.
(CID delta is one class, not the whole log — this is the fix to the original "every CID delta".)
- **Verbs are TRANSITIONS, not raw deltas.** `on-publish` = the draft→published transition
(fire-once), not every CID delta of a published post. The emitter picks the verb: `Create` on
first publish, `Update` on subsequent content edits, `Add`/`Remove` on relations, `Delete` on
unpublish/delete. Triggers are scoped to the transition, so re-editing doesn't re-fire on-publish.
- **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, split by DURABILITY not "complexity":** SYNCHRONOUS declarative logic authored
as **SX composition** (the execute-fold: effect/alt/each — eager, one-pass, NO suspend; live in
/workflow-demo). Anything needing a timer / suspend-resume / human-in-the-loop is a named
**Erlang flow** (next/flow/*.erl — flow_spec:sequence/branch/wait, effect-as-data, deterministic
replay). The execute-fold canNOT express `wait`; that's the escape-hatch boundary.
- **Effects are DATA; a DRIVER dispatches them.** Flows return effect descriptions (digest_sent,
a DigestSent activity) — they perform no IO (a blocking call deadlocks the er-scheduler). For P0
the HOST is the driver (dispatch the effect → a durable record / append the follow-up activity /
show it). The driver closing the loop back to object state is P4.
- **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 <CID> :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.** Detect the draft→published TRANSITION (edit-submit /a publish
action where prev status ≠ "published" and new = "published") — fire-once, not on every edit —
and emit host/blog--publish-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). RISK: run this in the handler BODY (never a
render/quasiquote — VmSuspended), and the flow must stay effect-as-data (no blocking, or the
er-scheduler deadlocks). SPIKE the SX→erlang-on-sx call in isolation first. (P3 swaps this for
real fed-sx delivery over next/kernel/http_server.erl.)
- [ ] **P0.3 — the effect is dispatched (host = driver).** pipeline:apply_triggers returns the
flow's effect-as-data ({digest_sent, Emails, DigestObject}); the HOST driver dispatches it —
P0: append a DigestSent record / activity + show it on a /flows page (or on the post). ACCEPTANCE:
publish a post on the LIVE host → the DigestSent surfaces, driven by the real flow. (Marshalling:
the SX activity dict ↔ the erlang proplist term is the fiddly part — factor host/blog--activity->erl.)
## 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.
- [ ] SYNCHRONOUS flows authored as SX composition (execute-fold: effect/alt/each — one-pass, no
suspend) → dispatched to the engine; anything needing wait/suspend/human-gate references a named
Erlang flow. (Optional bigger: extend the SX flow vocabulary with a `wait` that compiles to
flow_spec — only if authoring durable flows in SX proves worth it.)
## 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.
RISK: next/ delivery had M2 blockers (http-listen handler runs off the er-scheduler context →
gen_server:call can't complete; project_fed_prims_http_listen_scheduler). Confirm delivery is
green before depending on it. Also: the ACTOR MODEL (:actor "site" is a P0 placeholder) is
foundational here — peer_actors / follower_graph / per-author identity underpin who federates to
whom. Deferred through P0P2, but P3 needs it real.
## 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).