Demonstrates a DIFFERENT configuration on the same substrate: a post carries polls; a vote is the
same 'acquire, deduped by actor' shape as a booking, with money + capacity turned OFF.
- host/blog-add-poll: a poll is-a poll (field question), post --has-poll--> poll, options as option
posts (is-a option, field label), poll --option--> opt.
- host/blog-vote: one vote per voter per poll (host/blog--voted-in-poll? checks all options), records
option --voted--> voter. No capacity, no payment — a Claim with those axes off.
- host/blog--post-polls / --poll-view / --poll-form: results (per-option counts) + a vote form per
option + an Add-a-poll form, shown on every post page.
LIVE on blog.rose-ash.com/welcome: Dune 2 / Oppenheimer 1 / Barbie 0 (a repeat voter refused). Same
dedup as ev/book!, zero new mechanism. blog 218/218.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The events peer's ticket purchase now goes through lib/events' ATOMIC booking instead of a
read-check-write race — real domain logic dropping in under the same clickable cinema, exactly as
the seam promised.
- MODULES (serve.sh + conformance.sh): load lib/persist/concurrency.sx (append-expect/conflict?) +
lib/events/booking.sx. host/blog-store (persist/open) is stream-capable, same backend lib/events
tests use.
- host/blog-buy-ticket: replaced '(< (len sold) capacity)' with (ev/book! host/blog-store <showing>
<capacity> <actor>); proceed only on :status :booked. occ-key = showing slug, capacity =
host/blog--showing-capacity, actor = email + current roster len (unique per seat, collapses
double-clicks, allows a person to hold several seats). persist append-expect retries on conflict —
no oversell even under concurrent buys.
- Per-offering cap + the sold-edge display are unchanged (render-safe); ev/book! is the authoritative
gate in the handler.
VERIFIED LIVE on events.rose-ash.com: cap-2 showing → 3 buys, exactly 2 booked (3rd refused);
cap-1 showing → 10 CONCURRENT buys → exactly 1 sold, SOLD OUT. Sandbox: ev/book! returns
booked/booked/full/already for a cap-2 occ. blog 218/218 (with the two new modules loaded).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each showing's offerings are now independently editable — the 'some / all / extra + special offer,
different prices, different caps' from the cinema model.
- host/blog--offering-editor: a collapsible '⚙ Manage offerings' panel on the showing page — per
offering an inline price+cap Save form and a Remove button, plus an Add-offering form.
- host/blog-offering-update: edit an offering's price + cap.
- host/blog-offering-remove: unlink an offering from the showing (sold tickets keep their record).
- host/blog-offering-add: add an offering, CREATING the ticket type first if new (e.g. special-offer
→ seeds the ticket-type + is-a). host/blog--offering-showing resolves the parent showing.
- Per-offering CAP enforcement: host/blog--offering-available? (offering sold < its cap, else only the
showing capacity limits it). buy-ticket checks it and tallies offering --sold--> ticket per offering;
the tickets section shows 'type — £price (sold/cap)'.
This covers the layout-style variable caps too (seated / tables / standing = per-offering caps).
LIVE: on the Dune showing — set adult £12 cap 2, added special-offer £5 cap 1, removed u18; buying the
special-offer twice yields 1/1 sold (second blocked). blog 218/218.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
People can now buy tickets, from the web UI, with capacity enforcement — the heart of the model.
- Showing page (events): a 🎟 Tickets section (host/blog--showing-extras) shows capacity/sold + a Buy
form per Offering (ticket type + price). host/blog--showing-capacity = the showing's override else
its calendar's screen's default (via on-calendar → has-calendar reverse).
- host/blog-buy-ticket (events): CAPACITY-CHECKED (sold < capacity), then POSTs to shop /ticket and
records showing --sold--> ticket. Sold out → the Buy form is replaced by 'sold out'.
- host/blog-ticket (shop): issues a Ticket (is-a ticket, for showing, bought-as offering, owned-by
the person's email) + registers the person on the identity peer.
- host/blog-person (identity): find-or-create a Person keyed by email (login-optional) → person:<id>.
- IDENTITY is a new 4th fed-sx peer (sx_identity, SX_DOMAIN=identity, id.rose-ash.com-ready); shop
gets SX_IDENTITY_BASE. serve.sh gains shop 'ticket' type + identity 'person' type seeds.
LIVE end-to-end: events.rose-ash.com/<showing> → Buy adult (alice@example.com) → sold 0→1, a ticket
on market.rose-ash.com, a person on identity. blog 218/218.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Rose Ash Cinema object model, operable on events.rose-ash.com/cinema.
- host/blog-seed-cinema!: seeds the type-posts (cinema/screen/calendar/film/ticket-type/showing/
offering) + Rose Ash Cinema with two screens (each capacity 100 + a calendar) + ticket types
(adult/u18/concession/standing). Idempotent. Called from serve.sh's events block.
- /cinema page: screens (with capacity) → their calendars → showings; a Films list (each with its
ticket types); an Add-film form; a Book-a-showing form.
- host/blog-new-film: creates a film is-a film + default ticket types (adult, u18).
- host/blog-new-showing: books a Film onto a Calendar at a time (showing of-film / on-calendar,
calendar --scheduled--> showing), with an optional per-showing capacity override, and SNAPSHOTS
the film's ticket types as Offerings (offering of-type, showing --offers--> offering, price field).
- Views: host/blog--screens-view / --calendar-view / --films-view (all via out-raw for federated refs).
LIVE: events.rose-ash.com/cinema shows the two screens + calendars; add 'Dune' → film with adult/u18;
book Dune on cal-screen-1 Fri 8pm → a showing with offerings, listed under the calendar. blog 218/218.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The allocations live on the events PEER (events.rose-ash.com), not on blog — blog federates them
away. Repointed Caddy events.rose-ash.com → sx-dev-sx_events-1 (in /root/caddy/Caddyfile, external
to this repo; needed a 'docker service update --force caddy_caddy' because a single-file bind mount
is inode-pinned — editing swaps the inode). Added a '→ view on events' link on the blog allocate
form so the workflow is navigable. events.rose-ash.com/calendars now shows allocated posts +
scheduled events + ticket sales, publicly.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The whole vision, clickable end-to-end across THREE fed-sx peers (blog / events / shop), each a
lib/host instance with SX_DOMAIN-selected types + behaviors.
- EVENTS peer: a 'calendar' type (on-allocate → real allocated relation, via the driver now
PERFORMING relate effects) + an 'event' type. /calendars UI: allocated posts, scheduled events
(each with its featured post + a Buy-ticket button + sold count), and a Schedule-an-event form.
host/blog-new-event schedules an event on main, optionally featuring an allocated post.
- SHOP peer: an 'order' type. POST /order?event= creates an order (is-a order, related to the event)
→ 'order:<id>'. Replaces the Python shop service.
- BUY: events POSTs a cross-domain order to shop (host/blog--http-order), then links event--sold-->
order. host/blog--out-raw reads cross-domain edges (host/blog-out filters to local slugs, which
would drop federated refs — the bug that hid allocated posts + sold counts).
- BLOG: every post page shows an 'Allocate to a calendar' form.
- serve.sh: SX_DOMAIN gates blog/events/shop seeds; SX_EVENTS_BASE / SX_SHOP_BASE wire the chain.
docker-compose: sx_events + sx_shop peers (own stores, shared fed secret, externalnet-ready).
LIVE, all via the browser: allocate 'welcome' on blog → events /calendars shows it → schedule
'Summer Gig' featuring welcome → Buy ticket → shop order → tickets sold increments. blog 218/218.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Makes slice 1 clickable and real. The effect DRIVER now PERFORMS action-effects (closing the loop):
a 'relate' effect {:args (src kind dst)} mutates the relation graph. The events calendar behavior's
allocate-link DAG emits (effect relate (field target) 'allocated' (field slug)), so an allocated post
becomes a real main--allocated-->post edge on events (not just a log line).
UI: every blog post page shows an 'Allocate to a calendar' form (host/blog--allocate-form, shown when
a peer is configured) → POST /:slug/allocate reads the form field. events gets a /calendars page
listing posts allocated to 'main'. serve.sh seeds a 'main' calendar (is-a calendar) on events.
LIVE: submit the allocate form on blog.rose-ash.com/welcome → events /calendars shows 'welcome'
allocated to main, a real federated relation. blog 218/218.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first cross-domain federated workflow — behaviors defined by TYPES, across domains.
- events.rose-ash.com is now a fed-sx PEER: a lib/host instance with SX_DOMAIN=events whose 'calendar'
TYPE declares an on-allocate behavior. Replaces the Python events service (no strangler). serve.sh
gates domain types/behaviors on SX_DOMAIN (blog=article publish/digest; events=calendar+allocate).
- DIRECTED cross-domain delivery: an activity with :to <peer-base> is delivered to that peer's inbox
(∪ followers). The wire gains 'to'. So 'allocate' targets the events peer specifically.
- host/blog--allocate-activity/allocate! + POST /:slug/allocate?calendar=<id>; the events calendar
type's allocate-link DAG (an execute-fold effect) fires on receipt.
- docker-compose: the sx_events service (own store, shared SX_FED_SECRET, externalnet for a future
events.rose-ash.com Caddy route).
LIVE PROOF: publish 'Gig Night' on blog.rose-ash.com → POST /gig-night/allocate?calendar=main → the
events peer RECEIVES the directed, signed activity (/activities: 'allocate article gig-night') and
its calendar type's on-allocate behavior FIRES (/flows: 'linked gig-night'). blog 218/218, full
conformance green.
NEXT: events runs lib/events (real calendars/recurrence/ticketing); link event→post; shop
(lib/commerce) sells tickets — same federated, type-declared shape.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The full fed-sx production layer, live-verified across A (blog.rose-ash.com) and B (sx_host_b).
ACTOR MODEL + FOLLOWER GRAPH: activities carry a real :actor (SX_ACTOR); delivery targets FOLLOWERS,
not a static peer list. A peer subscribes by POSTing {verb:follow, actor, base} to /inbox
(host/blog--add-follower!); B follows A at boot (SX_FOLLOW) so A delivers to B. host/blog--{actor,
self-base, followers, follow!, delivery-bases} + durable followers store.
BACKGROUND DELIVERY TIMER: serve.sh's detached _fed_delivery_loop hits GET /fed-tick every 15s
(over /dev/tcp) → re-follow (idempotent, recovers a target that was down at boot) + flush the durable
outbox. Federation is eventually-consistent, not best-effort-at-emit.
SIGNATURE VERIFICATION: every federated POST is signed (host/blog--fed-sign = dr/sess-sig shared-secret
MAC over the body, SX_FED_SECRET); /inbox rejects a bad/missing signature with 403 (empty secret =
open). Applies to both follows and activity delivery.
PUBLIC DOMAIN: B joins externalnet so Caddy CAN reverse_proxy a subdomain to it — the DNS + Caddy
route itself is external ops config (no local Caddyfile).
LIVE PROOF: B follows A (followers:1); publish on A → SIGNED delivery to follower B → B verifies +
fires validate+notify; a forged POST (bad x-fed-sig) → 403; B down → publish queues → the background
timer auto-delivers the backlog when B returns (no manual flush). blog 218/218, full conformance green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Step 3 — federation, live-verified with TWO real host instances.
- host/ta.sx: host/ta--post/make-http-wire/federate (POST a serialized activity to a peer's /inbox
over real HTTP). host/blog.sx: POST /inbox (host/blog-inbox → receive! → process locally, does NOT
re-federate — no loops).
- DURABLE OUTBOX (fed-sx reliability, after the user asked 'if B is down does it still work?'):
emit! processes locally (always succeeds), QUEUES per-peer to a persisted outbox, delivers
best-effort. A peer being DOWN no longer fails the publish — delivery is GUARDED (SX guard catches
the http-request connection error), failed items stay queued and retry on next emit / on boot /
manual /flows?flush=1. /flows shows the outbox depth.
- serve.sh: SX_PEERS → peers; boot load+flush of the outbox. docker-compose: a 2nd host sx_host_b
(peer B, own store, no peers).
LIVE PROOF: (1) a peer POSTs create/article to blog.rose-ash.com/inbox → A fires validate+notify.
(2) publish on A → federates to B → B fires ITS behaviors on A's activity (B's /flows + /activities).
(3) RESILIENCE: publish with B DOWN → A returns 303 (was 500) + queues; start B + flush → B receives
the backlog + fires. blog 218/218 (+TA receive test), full host conformance green.
A = blog.rose-ash.com (public/Caddy); B = sx_host_b (internal docker DNS only, no public domain).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Steps 1+2 of RA-live/TA-live, live-verified end-to-end on blog.rose-ash.com.
(1) DEPLOY: docker-compose.dev-sx-host.yml gains an sx_kernel service running next/kernel/serve.sh
(the durable-execution kernel), SX_HTTP_HOST=0.0.0.0 so the host container reaches it at
http://sx_kernel:8930.
(2) HOST AS CLIENT: lib/host/ra.sx gains a KERNEL runner — host/ra--make-kernel-runner drives the
kernel over HTTP (http-request, native primitive; returns {status headers body}). It advertises
{effect,branch,each,suspend}, so select-runner routes a durable DAG to it. host/blog.sx: the DAG
registry + runner fleet are now mutable (register-dag!/add-runner!); emit! records SUSPENSIONS in a
durable pending log; /flows shows suspended instances with a resume link (?resume=<id>) driving
host/ra--kernel-resume. serve.sh wires it: set kernel-base, add the kernel runner, register the
durable 'blog-digest' DAG, declare a DURABLE behavior on article (create→publish SYNC, update→
blog-digest DURABLE), add a 'category' field.
LIVE PROOF: editing a published newsletter article → Update → routes to the kernel runner → POST
/flow/start/newsletter → kernel SUSPENDS (instance 5, shown pending on /flows) → /flows?resume=5 →
host re-drives the kernel → DONE → digest-sent effect + pending cleared. Durable suspend/resume
across separate HTTP requests, on a deployed persistent kernel. urgent edits complete immediately
(digest). http-request works in the serving context. blog 217/217, full conformance green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lib/host/ta.sx — a seam transport {:emit :deliver} over a DIRECTIONAL wire (out = outbox→followers,
in = inbox←follows). The transport is the SERIALIZATION boundary: activities cross the wire as
SX-source strings (host/ta--serialize/deserialize map the keyword-keyed activity ↔ a flat
string-keyed wire form of the P2 activity fields). host/ta--make-transport(out-wire, in-wire) +
host/ta--make-mem-wire (an in-memory directional queue for tests).
Proven (ta 5/5): content + relation activities round-trip through the wire; the FEDERATION LOOP —
instance A emits an activity → the wire carries it → instance B's behavior/pump delivers + processes
it → B's engine fires ITS behavior on A's activity; DIRECTIONAL (B re-emits to its own outbox, not
back into the inbox — no loop). 'Everything works over fed-sx', proven at the seam.
TA-live (deferred, same shape as RA-live): swap the mem-wire for the real next/ delivery wire —
needs a PERSISTENT next/ kernel (gen_servers don't survive across erlang-eval-ast calls) + the ACTOR
MODEL (peer_actors/follower_graph decide who the out-wire delivers to) + pushing /activities onto it.
Full host conformance green (+ta 5).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generalizes emission beyond publish to the full event source. TWO ActivityPub-faithful classes:
- CONTENT (host/blog--content-activity): Create on first publish, Update on a subsequent published
edit. object-type is DERIVED from the post's is-a (host/blog--post-type), not hardcoded 'article'.
- RELATION (host/blog--relation-activity): Add/Remove, carrying :relation + :target (the edge).
host/blog--emit! runs any activity through behavior/process (logged + matched). emit-content-change!
(create/update) wired into form-submit + edit-submit; emit-relation! (add/remove) wired into
relate-submit + unrelate-submit.
DEBT #1 FIXED — per-EVENT :id (not the bare CID): content = create:/update:+cid; relation =
add:/remove:+src:kind:dst (EDGE-based, because a relation change doesn't shift the CID, so a
CID-based id would false-dedup different edges on one object).
The activity log is now the DURABLE EVENT SOURCE (string-keyed records under 'activitylog',
boot-loaded), surfaced at /activities — what TA will push to peers.
LIVE PROOF (blog.rose-ash.com): publish → /activities 'create article <cid>'; relate → 'add article
p2-events — add welcome related'; unrelate → 'remove …'. blog 217/217 (+4 P2, reframed P0.3 fire
tests for Update semantics), full host conformance 614/614.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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
(Erlang refs resolve lazily inside lambdas) and is unit-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 a named
next/ flow via 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 (flows.sx): host/flow--required-caps now handles a {:erl-flow :needs} DAG
(declared caps, since a foreign flow can't be introspected); host/flow--select-runner picks the
cheapest runner whose capabilities cover the DAG's needs. The capability model is now REAL with two
runners — an {effect,branch} composition lands on exec-runner; a {suspend} DAG routes to RA.
Verified: ra 9/9 (mock erl-eval) + plans/ra-integration.sh 4/4 (the REAL module driving live
flow_store: urgent→done, newsletter→suspended with a resume handle, digest_sent effect-as-data).
Full host conformance 607/607; next/tests/triggers_e2e.sh 10/10 baseline intact.
FINDING → RA-LIVE deferred: gen_servers don't persist across separate erlang-eval-ast calls (flow
README), so true cross-call suspend/resume needs a PERSISTENT next/ kernel process. The runner +
marshalling + suspend/resume mechanics are proven; RA-live is process lifecycle + wiring, documented.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
REVIEW at the P0-complete milestone found one live bug and several forward prerequisites.
FIX (was live): edit-submit ran maybe-publish! BEFORE set-field-values!, so an edit that set a
category and published in one submit fired the publish activity on the STALE category (wrong branch).
Reordered — fields land before the transition fires. Regression test added (fields-first →
newsletter→digest, not stale→notify). blog 210/210.
Recorded carried-forward debt in the plan: activity identity (DEBT #1, blocks P2 — :id=CID false-
dedups relation events), capability bind not wired into the live engine (DEBT #2, P1), synchronous-
in-request dispatch (DEBT #3, RA needs the async boundary + background pump), the 'urgent' default
smell (DEBT #4). Sequencing note: P1's runner-derivation is vacuous until RA adds a 2nd runner, and
RA is the load-bearing risk — recommend a narrow RA spike next to de-risk the durable/federated half.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
host/blog--publish-activity now emits the CANONICAL seam shape {:verb :actor :object <cid>
:object-type :slug :category :delta :id}: :object is a content-addressed REFERENCE (the CID, not an
inlined dict), :id the dedup identity, :slug+:category the domain fields the DAG reads. Consumers
reconciled — the on-publish trigger matches :verb+:object-type; publish-ctx reads top-level
:category+:slug. Added host/blog--activity->erl: marshals the canonical activity → next/'s Erlang
proplist for the Erlang runner adapter (RA) — defined + tested, unused until RA so the reconcile is
complete and RA's bridge is ready. (:ts/:prev omitted — no clock primitive in the host; deferred.)
LIVE PROOF: published on blog.rose-ash.com → /flows fired validate+notify with the canonical
activity. blog 209/209, full host 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 shape, with the RA marshaller staged. RA (Erlang
runner) + TA (fed-sx transport) plug in next without touching the DAG or the wiring.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The driver now persists each effect record to the blog store (string-keyed to dodge the keyword/
persist top-level split), and host/blog-load-flowlog! rebuilds the in-memory log on boot (wired into
serve.sh after load-edges!). So /flows survives a restart — closing the P0.3 gap.
LIVE PROOF: published a post on blog.rose-ash.com → /flows showed validate+notify → RESTARTED the
container (in-memory log lost) → /flows STILL showed them, reloaded from the durable store.
Round-trip also covered by a conformance test (persist → clear → reload → identical). blog 208/208,
full host conformance 599/599. Note: whole-list rewrite per effect — fine at P0 volume, cap/rotate later.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Publishing a post now fires the on-publish behavior DAG through the seam. host/blog--{transport
(activity log), triggers (on-publish: create+article → publish-DAG), driver (records each effect in
the flow log), publish-engine (behavior/make-engine over the four adapters + the execute-fold runner
+ publish-ctx), fire-publish!, maybe-publish!}. Both write handlers (form-submit POST /new,
edit-submit POST /:slug/edit) detect the draft→published TRANSITION (fire-once) in the handler body
and run behavior/process. GET /flows renders the flow log (the effect-as-data the driver dispatched).
LIVE PROOF: logged in + POST /new on blog.rose-ash.com → /flows shows 'validate' + 'notify' (the
publish-DAG branched on the default urgent category), driven end-to-end by the real behavior engine.
Every piece is a seam adapter — swapping the runner for Erlang (RA) or the transport for fed-sx (TA)
federates this same wiring unchanged.
blog 207/207 (+4 P0.3), full host conformance 595/595. GAP: flow log is in-memory (P0.3b = persist).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per the user: the execute-fold-vs-artdag split from P0.2 is a capability SNAPSHOT, not a permanent
boundary. artdag MAY grow +{effect,branch,each} node-kinds; business logic then migrates onto it to
inherit the DAG-engine superpowers — 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 — the federation vision, for free). The capability model makes the migration
seamless (same DAGs + seam; the runner just advertises more). Named the real design work: dynamic
control in a static DAG (branch prunes a path); effect nodes non-cacheable vs pure nodes memoized.
Demand-driven (phase AX); execute-fold stays the lean default for cheap synchronous flows. Annotated
the P0.2 finding + flows.sx header so the finding doesn't harden into dogma.
Doc/comment-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The hypothesis test. FINDING: a synchronous business flow expresses NATURALLY as an EXECUTE-FOLD
composition (host/execute.sx: seq/effect/alt — the category branch IS 'alt'), NOT an artdag
DATAFLOW DAG (which has no control flow). So 'business logic = art-dag' holds at the ABSTRACTION
(both content-addressed op-DAGs) and is REFINED at the vocabulary: the synchronous control-flow
runner is the execute-fold (caps {effect,branch,each}); artdag is the dataflow sibling. Two
instances of one thing, run very differently — exactly the framing.
lib/host/flows.sx: capability typing (host/flow--node-cap/required-caps derive a DAG's capability
set from its node vocabulary; effect→effect, alt→branch, each→each, wait→suspend), the execute-fold
seam runner (advertises {effect,branch,each}), and host/flow--bind (required ⊆ advertised → derive
the runner, else fail-fast). host/blog--publish-dag (the publish workflow) + publish-ctx.
Verified: publish-DAG required-caps = {effect,branch} → binds to the sync runner; runs →
newsletter→[validate,digest] / urgent→[validate,notify] / other→[validate,skip]; a node →
{suspend} → binds FAIL-FAST against the exec-runner (would need the Erlang runner, RA). Runner is
DERIVED, not chosen. flows 7/7, blog 203/203, full host conformance 591/591.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Second review of the (core) seam caught a subtle one — and that my first 'fix' was itself wrong.
The async completion of a SUSPENDED durable flow happens AFTER the synchronous process call has
returned, so an :emit captured in the run env would be stale. The correct seam is construction-
wiring: a durable runner is wired to the transport's INBOUND channel at construction and injects
its completion activity there, out-of-band; a later behavior/pump drains it → effects flow. So the
engine code was already right (pump is the async re-entry seam); only the contract comment was
wrong — corrected. New test proves the loop: process(wait) suspends (no effect), then pump drains
the out-of-band completion → the flow's digest effect flows. Also clarified: dedup is per-
invocation (global idempotency = emitter fire-once + durable inbox); retry is flow-level; the
engine-facing runner result is {:status :effects :resume :error} (:results is runner-internal).
behavior 10/10 (+ async-completion). No engine change — comment + test only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
After review, the seam was only synchronous-complete; the durable/celery-sx runners couldn't
plug in cleanly. Additive fixes (pipeline unchanged): (1) :status branch in run-binding — 'done'
dispatches effects, 'suspended' records the flow + :resume (a durable runner holds it; completion
re-enters as a new activity via pump), 'failed' records + :error for retry/dead-letter. (2) richer
runner env — :ctx (per-activity, via engine :ctx-of) + injected :effects (external-read interfaces,
e.g. a deterministic fetch_followers). (3) dedup by content :id — a cycle is caught by identity,
not just the depth guard. (4) behavior/pump — drain transport.deliver for inbound (peer activities
+ async runner completions), sharing one trace so dedup spans the batch.
behavior 9/9 (+ suspended/failed/dedup/env/pump); full host conformance 580/580.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lib/host/behavior.sx — the substrate-independent seam every runner/transport/registry/driver
plugs into. An engine bundles four dict-of-functions adapters (trigger-registry, runner,
transport, driver); behavior/process folds an ACTIVITY through the pipeline: emit → match
triggers → run each behavior DAG → dispatch each effect-as-data → recurse on new activities
(loop closure, depth-guarded at 8). Every stage injected, so the same DAG + engine run over the
synchronous op-table runner / Erlang durable / celery-sx / fed-sx transport unchanged.
Reference tests (mock adapters) prove the contract: publish→trigger→runner→effect flows; a
non-matching activity fires nothing (log complete, execution precise); an effect that emits a new
activity re-triggers (loop closes); an unbounded loop is depth-guarded (terminates). Wired into
conformance.sh + serve.sh MODULES. behavior 4/4; full host conformance 575/575.
Next: P0 supplies the REAL adapters (publish activity ← host/blog--publish-activity, local-SX
trigger, sync op-table runner over a publish-DAG, host driver) — same engine.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Business logic as federated composition-flows (plans/business-logic-fed-flows.md). P0.1: the
host describes a published post as a fed-sx activity — host/blog--publish-activity(slug) →
{:type "create" :actor "site" :id <CID> :object {:type "article" :slug :category}} — the
exact shape next/'s trigger machinery consumes (verified: next/tests/triggers_e2e.sh 10/10).
category (drives the flow branch: newsletter suspends / urgent fires / else skip) comes from
the "category" field-value, else the first tag, else "urgent". + host/blog--post-category.
Design decided: activity log = every CID delta (event source); triggers = declared subscriptions
(DefineTrigger); flows hybrid (SX composition for simple via the execute-fold, named Erlang flows
for complex); federated execution = Erlang (next/); the type carries content+relations+behavior.
blog 200/200 (+3: contract, category fallback, missing-post nil).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dashboard gains a per-route latency bar chart (nested p50/p95/p99 bars, tail
visible) + status-colored waterfall with ms duration labels + a real 3s
auto-refresh (replacing the non-functional data-on-load SSE attr). serve.sh
self-warms the serving JIT over /dev/tcp so the first visitor after a restart
gets ~78ms not the one-time ~2.5s compile. otel suite 125/125.
The otel dashboard route (GET /otel) is single-segment, so blog-routes' /:slug catch-all
shadowed it (404 'no post: otel'); only /otel/stream (two segments) survived. Move otel/routes
ahead of the blog routes. Live-only wiring fix (route order); no test change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds lib/host/otel.sx to serve.sh MODULES and otel/routes to the host/serve
group list so GET /otel (+ /otel/stream) serve on the live host once merged.
Build-time wiring only; no container touched.
otel/format-traceparent + otel/current-traceparent emit '00-<32hex>-<16hex>-01';
otel/parse-traceparent round-trips it (nil on malformed/bad-width). otel/-timed
now guards the thunk: success spans get :status ok, a raised error records a
span with :status error + an exception event then propagates. Error propagation
uses a false-returning guard clause test (an explicit (raise e) in a guard
handler re-enters the guard and hangs).
otel/export-otlp folds spans → OTLP/JSON envelope (resourceSpans → scopeSpans →
spans) with hex traceId(32)/spanId(16)/parentSpanId, uint64-as-string nano
timestamps, typed attributes (stringValue/intValue), and span kind
(SERVER/INTERNAL). otel/export-otlp-json encodes via dream-json-encode;
otel/post-otlp POSTs through an injected transport (testable without a live
collector).
otel/dashboard SSRs the metrics strip + latest-trace waterfall + recent-traces
list as HTML carrying Datastar-style data-on-load subscribing to /otel/stream,
the SSE feed of SXTP otel.span events. Routes otel/dashboard-route +
otel/stream-route (otel/routes) mount via make-app. recent-traces/latest-trace
+ otel/span-event helpers.
otel/metrics folds spans → {:total-requests :routes}; each route carries a
request count and nearest-rank latency percentiles over its durations. Route
key is the http.route attr (falls back to span name). Includes a small
insertion sort (no sort primitive) and order-preserving distinct.
otel/waterfall-rects folds a trace's spans into rect geometry (x by start
offset, width by duration, y by depth via parent-link ancestor count);
otel/waterfall folds those into an inline <svg> (one <rect>+<text> per span).
Renders to real SVG markup via the html tag registry.
otel/instrument-routes wraps each flattened Dream route's handler in a timed
span named METHOD /route with {:http.method :http.route :http.status} attrs;
host/make-app applies it so every matched request becomes a trace. Refactored
with-span onto a shared otel/-timed core that takes a finalize fn for
result-derived attrs (the http.status only known post-handler).
Clamp against a high-water mark so the clock never steps backwards; span
durations stay non-negative. Real ns-scale timestamps replace the P1
placeholder counter.