Three framing fixes after review: (1) the event source is object-level state changes, NOT just CID deltas — relations write edge:* rows so they don't shift the CID; content/status → Create/ Update, relations → Add/Remove (ActivityPub-faithful). (2) verbs are TRANSITIONS (on-publish = draft→published, fire-once, not every delta of a published post). (3) the hybrid flow split is by DURABILITY not complexity — the execute-fold is eager/synchronous (no wait); suspend/timer/human flows are the Erlang escape hatch. Plus: effects-as-data need a DRIVER (host, for P0); P0.2 must gate on the transition + run in the handler body (VmSuspended/er-scheduler risk); P0.3 gets an acceptance criterion; P3 flags the fed-sx delivery M2 blocker + the deferred actor model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7.0 KiB
7.0 KiB
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; 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-carryingCreate/Update(the record's canon incl. :status → the CID); relation change (relate/unrelate/tag) → anAdd/Removeactivity 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:Createon first publish,Updateon subsequent content edits,Add/Removeon relations,Deleteon 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.
- 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. 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
waitthat 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 P0–P2, 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).