; -*- mode: markdown -*- # loops/fed-sx-triggers — activity-driven flow triggers Scoped briefing for a focused iteration loop that wires fed-sx activities to durable business flows on top of `lib/flow/`. Every arriving activity (federation OR local publish) can fan out into one or more registered flows; the flow engine handles multi-step workflows, branches, side effects, retries, and human gates. Companion to `plans/fed-sx-design.md` §13 (delivery / projection model) and `plans/flow-on-sx.md` (flow engine surface). This is the build sheet for the substrate side of "an activity fires a business flow." ``` description: loops/fed-sx-triggers — activity → flow dispatch (substrate) subagent_type: general-purpose run_in_background: true isolation: worktree # /root/rose-ash-loops/fed-sx-triggers ``` ## The mental model ``` activity arrives ─→ pipeline validates ─→ kernel appends ─→ fan-out │ trigger_registry lookup │ ┌───────────────┬───────────────────┴────────┐ ↓ ↓ ↓ flow:start(F1) flow:start(F2) flow:start(F3) │ │ │ (suspend, (sync side (multi-step: resume on effect: payment → ship timer) send email) → notify, with branches) ``` Activities are the unit of "something happened." Flows are the unit of "what we DO about it" — composable, durable, branching. ## Concrete examples (motivating) - **Blog publish** (`Create` for an `Article`): index in search, send digest to followers (per-actor preference dictates batching), push to federation peers, invalidate caches. Branch: if the article references unpublished drafts, hold publication until they land. - **Order placed** (`Create` for an `Order`): charge payment → on fail, cancel + notify; on success, allocate inventory → on out-of-stock, refund + notify; on success, schedule shipping + send confirmation + start a follow-up flow for "ask for review in 14 days." - **Comment posted**: run moderation pipeline → on fail, hold for human review (suspend until a moderator activity arrives); on pass, notify the post author + bump comment count + check for follow-up triggers. - **Membership renews**: bill the card → on fail, retry 3 days later; on terminal fail, downgrade access + notify. Suspend for 364 days, then fire again. All of these are 5-20 step flows with branches, retries, suspensions on external events, and human gates. They are `lib/flow/` territory — m2's `delivery_worker` retry is much simpler (one HTTP call, fixed backoff) and lives in `next/kernel/`, not here. ## Scope **In scope** — `next/**` only. Four pieces: 1. `DefineTrigger` genesis activity-type + `trigger_registry.erl` 2. Trigger fan-out stage in `pipeline.erl` (post-append) 3. `flow_dispatch.erl` — bridges activity → `lib/flow/` start call 4. End-to-end integration test exercising a multi-step flow with one branch and one suspension **In scope to IMPORT from** — `lib/flow/**` is a public dependency. This loop calls `flow:start/2`, `flow:resume/2`, etc. as a consumer; it does not modify `lib/flow/`. **Out of scope** — `lib/host/**`, `lib/erlang/**`, `bin/sx_server.ml`, any `lib//` directory. Touching them would conflict with parallel loops. **Parallel-safety with `loops/fed-sx-types`** — that loop also edits `pipeline.erl` (adding `apply_object_schema/2` between activity-type validation and append). This loop adds a fan-out stage AFTER append. Different positions in the pipeline. Resolve by inserting both stages in a single commit OR by branching this loop from fed-sx-types' tip once fed-sx-types completes. **Default: branch from fed-sx-types' tip.** If fed-sx-types is mid-flight, wait for it to land Phase 4 before starting this loop's Phase 2. ## Branch base `origin/loops/fed-sx-types` if it exists and has Phase 4 landed. Else `origin/loops/fed-sx-m2` (has all m2 substrate + send_after + http-listen + peer_actors pattern). Either base has flow-on-sx available via `lib/flow/**`. ## Phase 1 — `DefineTrigger` + `trigger_registry.erl` ### `next/genesis/activity-types/define_trigger.sx` Verb declaration. The `:object` payload binds an activity-type (possibly per-actor, possibly with a guard predicate) to a flow name: ```sx (DefineTrigger :name "Trigger" :doc "Bind an activity-type to a flow. When a matching activity is appended, fan out to flow:start with the activity as input." :schema (fn (act) (and (string? (-> act :object :activity-type)) (string? (-> act :object :flow-name)) ;; guard is optional — a (fn (activity) ...) returning bool (or (nil? (-> act :object :guard)) (callable? (-> act :object :guard))))) :semantics (fn (state act) ;; Folds into the actor's :triggers projection — registry on ;; restart hydrates from this. (trigger-state-add state act))) ``` The `:guard` lets one type bind to multiple flows with discriminators ("only fire `EmailDigest` for Articles with `:category "newsletter"`"). Optional; default true. ### `next/kernel/trigger_registry.erl` In-memory registry, hydrated from the actor's trigger projection on start. Mirrors `peer_actors.erl` / `peer_types.erl` shape: ```erlang trigger_registry:start_link(_) -> Pid trigger_registry:add(ActivityType, Spec) -> ok trigger_registry:remove(Trigger Cid) -> ok trigger_registry:lookup(ActivityType) -> [Spec] trigger_registry:all_triggers() -> [{ActivityType, [Spec]}] ``` Where `Spec` is `{trigger_cid, flow_name, guard_fn|undefined, actor_scope|any}`. Multiple triggers per activity-type are supported and fire independently. ### Tests — `next/tests/define_trigger.sh` + `next/tests/trigger_registry.sh` - Verb round-trips through term_codec - Schema accepts well-formed, rejects missing fields - Registry: add/lookup/remove, lookup with no matches → [], multi-bind one activity-type → list of specs - Restart hydration from a fold over Trigger activities Phase 1 acceptance: 12-14 tests. ## Phase 2 — Pipeline fan-out stage In `next/kernel/pipeline.erl`, add a stage **after the kernel append** (not before — fan-out fires only on successfully accepted activities; rejected activities don't trigger flows): ```erlang %% Pipeline (revised): %% 1. envelope shape %% 2. signature against peer-AS %% 3. activity-type :schema %% 4. object-schema validation (fed-sx-types Phase 4) %% 5. kernel append %% 6. trigger fan-out (THIS PHASE) apply_triggers(Activity, ActorState, Cfg) -> Type = activity_type_atom(Activity), Specs = trigger_registry:lookup(Type), Matching = [S || S <- Specs, guard_passes(S, Activity, ActorState)], [flow_dispatch:start(Spec, Activity, ActorState, Cfg) || Spec <- Matching], ok. ``` `apply_triggers` is fire-and-forget from the pipeline's POV — the kernel response doesn't wait for flows to complete. Each spawned flow is its own erlang process; failures are isolated to that one flow. ### Idempotency Federation deduplication is a known weak spot: the same activity can arrive twice via different peers. Fan-out must therefore de-duplicate per `{activity-cid, trigger-cid}` pair before calling flow_dispatch. The `:triggers_fired` actor-state field holds `[{activity_cid, trigger_cid}]` of historical fires; before calling flow_dispatch, check membership; on dispatch, add. ### Tests — `next/tests/pipeline_triggers.sh` - Activity append → trigger lookup → flow_dispatch invoked - Activity with no matching trigger → no dispatch - Guard returns false → no dispatch - Same activity appended twice → flow_dispatch invoked once - Failed flow_dispatch (raises) does not block the kernel append - Multiple triggers for same activity-type → each dispatched Phase 2 acceptance: 10-12 tests. ## Phase 3 — `flow_dispatch.erl` The bridge from "an activity matched a trigger" to "a flow started with that activity as input." Calls into `lib/flow/`'s public API. ```erlang %% flow_dispatch:start/4 starts the flow named in Spec with %% the activity bound as :activity in the flow's input env. The %% started flow's id is returned (or {error, _} on failure). flow_dispatch:start(Spec, Activity, ActorState, Cfg) -> FlowName = field(flow_name, Spec), Input = [{activity, Activity}, {actor, actor_state:actor_id(ActorState)}, {trigger_cid, field(trigger_cid, Spec)}], case flow:start(FlowName, Input) of {ok, FlowId} -> {ok, FlowId}; {error, Reason} -> log_flow_start_failure(Spec, Activity, Reason), {error, Reason} end. ``` The `Input` proplist becomes the flow's initial environment. Flow steps can read the activity, the actor id, and the trigger cid (for audit chain). ### Audit chain Every flow started from an activity records its `{activity_cid, trigger_cid, flow_id}` triple to the actor's projection. Replaying the actor log + the trigger registry reconstructs which flows fired for what reasons. Same content-addressing discipline as the rest of fed-sx. ### Tests — `next/tests/flow_dispatch.sh` - Successful flow start records audit triple - Flow names that don't resolve in the flow registry → log + return `{error, no_such_flow}`; don't crash - Flow's first step runs synchronously before flow_dispatch returns (proves the activity payload landed in the flow's env) - Multi-step flow: trigger + first step + suspend on `flow:wait_for_message`; resume on synthetic message → second step completes - Branch test: flow has if/else gate on activity field; both branches exercised Phase 3 acceptance: 10-14 tests. ## Phase 4 — End-to-end integration: blog-publish digest A motivating, multi-step business flow that ties everything together. Lives at `next/genesis/flows/blog-publish-digest.sx` as a flow definition consumable by `lib/flow/`. Demonstrates: - Multi-step (3+ named steps) - Branch on activity field (article :category) - Side effect (mock email sender via `:dispatch_fn` Cfg hook) - Suspension on a timer (e.g. "send digest at 9am the next day") - Completion records a `DigestSent` activity (closing the loop — the flow's own output is itself an activity, which can in turn trigger downstream flows) ### `next/genesis/flows/blog-publish-digest.sx` ```sx (defflow blog-publish-digest (step :validate (let ((art (-> $input :activity :object))) (if (= (-> art :type) "Article") (continue :decide-batch art) (done :skipped)))) (step :decide-batch (fn (art) (case (-> art :category) :newsletter (continue :batch-until-morning art) :urgent (continue :send-now art) :else (done :skipped)))) (step :batch-until-morning (fn (art) (wait-until (next-morning-utc)) (continue :send-now art))) (step :send-now (fn (art) (let ((followers (fetch-followers (-> $input :actor)))) (for-each follower followers (dispatch-email follower art)) (continue :emit-digest-sent art)))) (step :emit-digest-sent (fn (art) (publish-activity (Create :object {:type "DigestSent" :for (-> art :id) :follower-count (-> art :follower-count)})) (done :ok)))) ``` The exact `defflow` syntax is what `lib/flow/` already provides — mirror its existing test fixtures (`lib/flow/tests/programs/*.sx`) for the canonical surface. ### Integration test — `next/tests/triggers_e2e.sh` End-to-end: 1. Bootstrap an actor with a `Trigger` activity binding `Article` → `blog-publish-digest`. 2. Mock `dispatch_email` and `fetch_followers` via Cfg hooks. 3. Publish a Create-Article activity with `:category :urgent`. 4. Assert: flow ran to completion within one append cycle, three emails dispatched, `DigestSent` activity appended. 5. Publish a Create-Article with `:category :newsletter`. 6. Assert: flow suspended after `:decide-batch`; advance the logical clock 16 hours; flow resumes; emails sent; `DigestSent` appended. 7. Publish a Create-Article with `:category :draft`. 8. Assert: flow took the `:else` branch, no emails, no `DigestSent`. Phase 4 acceptance: 8-10 e2e tests. ## Out-of-scope deliberately - **In-process local subscriber path on delivery_worker.** Right now `delivery_worker` dispatches over HTTP; an "internal sink" abstraction would let activities fan out via the delivery_set to a local consumer. That's a separate, smaller change in `delivery_worker.erl` + `outbox.erl`. Keep it out of this loop to keep the scope tight. - **Per-flow auth / capability gates.** Triggers fire flows with the activity's actor identity. Authorisation of what flows can publish what activities is a separate concern handled by the existing pipeline. - **Flow versioning / hot-reload.** When a flow definition changes, in-flight instances continue with the version they started on. New instances pick up the new version. flow-on-sx handles this; nothing to do here. - **Cross-actor triggers.** A trigger lives on the actor it's defined for. "When ANY actor publishes an Article, fire this flow" is a fan-in pattern that needs a different design. ## Tests discipline - `bash lib/erlang/conformance.sh` green before AND after every commit (substrate not touched, should stay 771/771). - `bash lib/flow/conformance.sh` green before AND after (the flow engine is a dependency we use as-is — if our changes break it, we did something wrong). - Commits scoped to `next/**` and `plans/`. No edits to `lib/flow/**`, `lib/host/**`, `lib/erlang/**`, `bin/sx_server.ml`, or other `lib//**`. - One commit per phase as outlined. - Push to `origin/loops/fed-sx-triggers` after each commit. ## Done when - Phases 1-4 land with their acceptance tests green. - The blog-publish-digest e2e test demonstrates a multi-step flow with a branch, a suspension, a side effect, and a follow-up activity emit, all driven by one trigger arriving in the pipeline. - `plans/fed-sx-design.md` updated (small note in §13) describing the trigger fan-out stage as a kernel-level convention. When this loop closes, the substrate supports declaring business flows as data, binding them to activity-types as Define\* activities, and seeing them fire automatically whenever the matching activity lands — locally OR via federation. The whole "event-driven CQRS" pattern that the user asked about works end-to-end with `lib/flow/` doing the actual workflow lifting. ## Parallel safety with other loops - `loops/host` — no overlap (next/ only). - `loops/fed-sx-types` — pipeline.erl overlap. Branch base on fed-sx-types' tip OR coordinate via a single combined pipeline-stage insertion commit. - `loops/erlang` — no overlap (lib/erlang/ untouched). - `loops/flow` — no overlap (lib/flow/ consumed as a dependency, never edited).