Failing tests first (2 red: relate! wrote no adjacency streams). Every edge write now maintains
per-pair event streams (rel:src|kind ← {:dst}, rin:dst|kind ← {:src}); host/blog-out/-in/--out-raw
(+ new --in-raw) fold ONLY the pair's stream — O(edges of that node under that kind) instead of
O(all kv keys) per read. Append-only ⇒ no read-modify-write race (duplicate :adds fold to a set).
The edge:* kv rows remain (whole-graph consumers: subtype-closure, relations admin block) and feed
host/blog-reindex-edges! — the idempotent boot migration serve.sh now runs, so pre-H7 live stores
read correctly. Collapsed host/blog--add-edge-kv! into add-edge! (the type-algebra conj/disj edges
were bypassing the streams — caught by the existing algebra tests going red).
blog suite 256/256 (+6); FULL conformance 658/658.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Failing tests first (3 red: a redelivered activity reran its behavior — behavior/process starts
from an empty trace, so dedup evaporated per call). host/blog--process-local! now atomically
claims the :id on persist stream 'activities:processed' via ev/book! (the same append-expect
acquire as seats/votes) and returns a :deduped trace on duplicates. Store-backed → survives outbox
retries AND restarts. Prerequisite for non-idempotent effects (payment). Id-less activities process
unchecked.
blog suite 250/250 (+3).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Failing tests first (red: a failed mint left the seat consumed — the cross-domain leak; a RAISING
mint escaped the handler entirely, proving no guard). host/blog--mint-ticket is now an injectable
seam (default: signed HTTP to the shop). buy-ticket: ev/hold! reserves (capacity-counted, atomic) →
mint runs inside (guard (e (true "")) ...) → 'ticket:' ⇒ ev/confirm! + sold edges + a 'sell'
activity (H4's missing emission); anything else ⇒ ev/release! frees the seat. Held seats count
toward capacity, so a pending mint can't be oversold either.
blog suite 247/247 (+3).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Failing tests first (6 red: no cinema/poll handler emitted anything — films/showings/votes were
invisible to federation and other peers' behaviors). Now: new-film→create(film),
new-showing→schedule(showing), offering-add→offer, offering-update→update (id carries new values so
distinct changes federate, replays dedup), offering-remove→retract, add-poll→create(poll),
new-event→schedule(event), vote→vote(poll) — voter kept OFF the wire (seat number makes the id
unique; pinned by test). All through host/blog--emit! (engine + durable activity log + outbox).
buy-ticket's sell emission lands with H5's injectable mint.
blog suite 244/244 (+6).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Failing tests first (2 red: the vote wasn't on the stream, and dedup vanished if the projection
edge was removed — proving it was an edge-scan). host/blog-vote now acquires on stream
'vote:<poll>' via ev/book! (append-expect, retry-on-conflict — the same atomicity as seats);
the option --voted--> edge is a projection recorded only on :booked. Removed the read-check-write
host/blog--voted-in-poll?. Governance-grade: no double vote under concurrency, dedup survives
projection wipes + restarts (store-backed).
blog suite 238/238 (+4).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Failing tests first (6 red: unauth /new-film created a film, etc). new-film / new-showing /
offering-add|update|remove / add-poll / new-event moved from the public route list into
host/blog-write-routes behind protect-html — same gate as every blog write. /vote, /buy-ticket,
/buy stay public (voters + customers) with explicit tests pinning that.
blog suite 234/234 (+9).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Failing tests first (4 red: unsigned POSTs returned 200 and minted objects), then the gate:
host/blog--int-verify? checks x-int-sig = sess-sig(fed-secret, request TARGET) (params live in the
query, body is empty); host/blog--protect-internal wraps the three routes → 403 unsigned. Secret
unset = open (dev/tests). Callers (events→shop /ticket + /order, shop→identity /person) sign via
host/blog--int-headers. Closes the live capacity-bypass (anyone could mint tickets directly).
blog suite 225/225 (218 + 7 new).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
host/blog-seed-nt-live-encore! now embeds the RAW Ghost HTML (from rose-ash.com/rss) and
imports via the "html" field, so host/html->sx converts it at boot — no more pre-converted
sx_content from the external Python script. Verified: the converter produces the identical 11
cards (card-image/text ×4 pairs + 3 card-embed), handling the real post's kg-card comments,
srcset, and nested figcaption markup. blog 197/197.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lib/host/htmlsx.sx — a pure-SX HTML → SX converter (char-level tokenizer + stack parser):
host/html->sx turns a post's HTML into an (article …) tree that host/blog--decompose! consumes
— img / p / figure+figcaption / iframe / headings / blockquote / lists, inline strong/em/a kept
nested (decompose flattens to text), entities decoded to UTF-8, comments+doctype skipped. This
replaces the one-off external Python converter used for the nt-live-encore import.
import-post! now accepts a raw "html" field (converted via html->sx, serialized to sx_content,
decomposed) alongside "sx_content" — so importing real Ghost HTML is first-class. Wired
htmlsx.sx into conformance.sh + serve.sh module lists (loads in conformance AND live).
New htmlsx suite 8/8 (text/entities/void/nested/figure/iframe/comments + an html→sx→decompose→
typed-cards round-trip); blog 197/197 (+ import-from-html test).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
host/blog-seed-landing-demo! (+ host/blog--seed-card! fixed-slug helper): a Landing TYPE with
TWO composition fields — :body (heading/text/image + cond/each) and :aside (text/callout, no
controls) — plus a populated landing-demo instance, wired into serve.sh (survives wipes),
idempotent (fixed card slugs, set-comp! overwrites). /landing-demo/ renders both fields; its
edit page shows two independent block editors (#comp-body, #comp-aside); /landing/ reads the
two-field definition. Demonstrates layer 2 end to end on the live site.
blog 196/196 (+ tests: idempotent 2-field seed, both fields render).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every type post reads as schema + extension. Added host/blog--type-population (host/blog--take
helper): a type's page shows its instances (posts is-a it, first 24 + count) and its subtypes
(is-a / subtype-of inverses), next to the read-only type definition. Injected in host/blog-post
when host/blog--is-type?. So /article/ shows what an article IS *and* which posts are articles;
/card/ shows its subtypes; every card type / tag / type reads its own definition (all are
is-type?).
blog 194/194 (+ tests: population lists instances + count, a parent type lists subtypes, GET
/article/ shows Population).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A type post's public page (/article/) now shows a read-only Type-definition panel: its fields,
each Composition field's block grammar ("may contain: heading, text, image, …; control blocks:
cond, each"), and the relations its instances may use — so anyone can read what a type IS, not
just admins on the edit page. host/blog--type-def-view (the read form of host/blog--type-def-
editor's data); injected in host/blog-post after the body when host/blog--is-type?.
blog 191/191, full conformance 420/420 (+ tests: the view renders fields/grammar/relations;
GET /article/ shows it, an instance's page doesn't).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
related / is-a / subtype-of / tagged are part of an object's composition (external — NOT in the
CID), and the TYPE declares which relation kinds its instances may use (:type-relations; absent
-> all kinds, so metamodel types keep full freedom). host/blog--{all-rel-kinds, type-relations,
set-type-relations!, allowed-relations, relation-allowed?}. The relation editors filter to the
permitted kinds; relate-submit ENFORCES it. article declares (related is-a tagged) — an article
instance can't be subtyped. The type-def editor (Part C) gains a relation CHECKLIST + POST
/<type>/relations, so the type's inline block-grammar AND external relations are edited in one
place: "it's just more composition."
blog 189/189 (+ Part B tests: allowed-relations excludes subtype-of for article, editors filter,
relate rejects a forbidden kind, checklist renders, POST /relations sets it). Full conformance
deferred — the sibling OTel loop is contending on the shared warm-conf dir; Part B touches only
blog.sx, so the other 7 suites are unaffected. Verifying live instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"It's just more composition": a type post's edit page now shows a Type-definition editor —
each field as name:type, and each Composition field with a GRAMMAR CHECKLIST (a checkbox per
card kind = permitted, + conditional/repeater toggles). Editing it changes what the type's
instances may contain. host/blog--{is-type?, set-field-grammar!, own-field, checkbox,
grammar-form, type-def-editor}; POST /<type>/grammar reads the checklist (uniquely-named
blk-<ct> / allow-<ctrl> boxes, since form fields are single-value) → set-field-grammar!.
Shown only when host/blog--is-type? (declares fields, or subtype-of type) — a type's page has
it, an instance's doesn't.
blog 184/184, full conformance 413/413 (+ Part C tests: is-type?, set-field-grammar!, the
checklist renders, POST /grammar sets it, appears on a type page not an instance's).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The type now GOVERNS the composition, not just declares the slot. A Composition field carries
its grammar: {:name "body" :type "Composition" :blocks (…card types…) :allow ("cond" "each")}.
:blocks absent -> any card subtype (back-compat); :allow absent -> both control blocks.
- host/blog--{field-decl, allowed-blocks, allows-control?, block-allowed?, comp-violations}.
- The editor PALETTE is the grammar: one <option> per allowed card type (spliced as direct
<select> children), and the conditional/repeater add-forms appear only if :allow permits.
- block-add-submit ENFORCES it (was a coarse "any card subtype" check) — the type governs writes.
- comp-violations flags a composition holding a forbidden block (the save/import gate).
- article declares its :body grammar (all 7 card kinds + cond/each).
blog 179/179, full conformance 408/408 (+ grammar tests: allowed-blocks/allows-control?,
palette shows only permitted kinds, add rejects a forbidden card, violations flags one).
Part B (relations as type-governed composition) + Part C (edit the type definition) next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
host/blog-seed-nt-live-encore! imports the real post (its HTML-derived sx_content embedded)
via host/blog-import-post!, decomposing it into the :body composition of typed cards; wired
into serve.sh next to the demo seeds. Verified: after a full store wipe + reboot it reseeds
(HTTP 200, 4 images, 3 video embeds, tagged nt-live/films). Idempotent. blog 175/175.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prep for importing a real blog post into the :body composition:
- article now DECLARES {:name "body" :type "Composition"} (layer 2 — the type defines that an
article's body is a composition). The edit FORM + submit read scalar-fields only, so the
Composition field never gets a stray text input (or gets nil'd on save).
- decompose handles real-post block kinds: <figure> → card-image WITH its <figcaption> as the
caption (host/blog--find-child digs out the inner <img>); <iframe>/<embed>/<video> →
card-embed with src as :url. card-embed's template now renders an actual <iframe> (videos
play) instead of the url as text.
blog 175/175, full host conformance 404/404 (+ test: figure→card-image(caption) & iframe→
card-embed via import). Next: wipe content (reseed types+demos), import nt-live-encore.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
:body was hardwired; now a TYPE declares which of its fields are compositions
({:name "body" :type "Composition"}), and an object may carry several (:body, :aside, :body-1).
The edit page renders ONE block editor per declared field (host/blog--block-editors →
host/blog--composition-fields → the type's Composition fields, default ["body"]); each editor
is independent, targets #comp-<field>, and its cards get field-qualified slugs
(<container>__<field>__<name>). Every block op takes a `field` (threaded via a hidden "field"
input, so routes are unchanged); the response re-renders just that field's editor.
STORAGE: compositions moved into a STRING-KEYED sub-dict :comps (like :field-values) —
string keys round-trip through persist cleanly, whereas a mix of a keyword :body and a string
"body" top-level key does NOT survive serialization as one key (it splits the data). body-of/
set-body! delegate to comp-of/set-comp! with "body" + a legacy top-level :body read fallback,
so existing bodies still render (the demos reseed into :comps on boot).
blog 174/174, full host conformance 403/403 (+ tests: a Landing type with two Composition
fields → two independent #comp-body/#comp-aside editors; block-add! to a named field; default
[body]). Editor still renders any node kind (no "unknown block"); #block-editor wrapper kept
so the Playwright selectors hold.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The block editor assumed cards-as-objects leaves (ref/alt-with-refs/each-with-ref), so a
hand-authored composition (the compose-demo: text/row/alt-with-text/each-with-inline) fell
through to "(unknown block)" for every text/row node. Now every node kind gets a labelled row
+ preview + move/remove controls: card (✎ chip), text (its content), layout (row/grid + item
count), field, group, and a graceful "other". Conditionals/repeaters display each branch via
host/blog--node-display (a ref → ✎ chip, else the inline text/summary) instead of assuming a
ref. host/blog--node-kind extended (text/layout/field/group); +node-display/+branch-display.
TEST-FIRST: a mixed body (text + alt-with-text + row + each-with-inline) asserts the editor
has NO "unknown block" and labels text/layout/for-each. RED before, GREEN after. blog 171/171.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The block editor now edits the object's ONE root composition (:body) as three block kinds —
CARD (a ref leaf, the "and"/content), CONDITIONAL (alt+when, the "or": render the first
branch whose live-context condition holds), and REPEATER (each: render a template per graph
query). The render-fold already interprets seq/alt/when/each/ref, so authored compositions
render for free; this adds the editing model + UI.
ADDRESSING (per the design discussion — refs are IPNS-like, not frozen CIDs): refs are
RELATIVE-STORED + RESOLVE-IN-CONTEXT. A :body stores (ref "body__b0") (field-relative); the
render context carries the CONTAINER (the object being rendered) and the resolver combines
them -> the card's storage slug <container>__<field>__<name>. So a body is portable (doesn't
pin the container's name), and editing a card updates everything that refs it for free (no
cascade). A cross-domain ref is absolute with an authority ("market:…"); the resolver
dispatches on the prefix (local today, fetch_data/AP later). A compat shim resolves an older
absolute ref directly. (Snapshot-to-absolute-CID stays a future on-demand op; the CID —
hash(record incl :body) — is the immutable layer over this naming layer.)
MODEL: host/blog--{card-slug,resolve-ref,slug->ref,new-card!,node-kind,node-refs,node-pred,
node-each-type,cond->pred,pred->ckey}; block-add!/add-cond!/add-each!; index-addressed
block-move-idx!/remove-idx!/set-cond! (alt/each aren't single refs). UI: host/blog--block-row
renders by kind (card / "if <cond> → … else → …" / "for each <type> → …") with a condition
<select> + ✎ links to each card's own /<cslug>/edit (external object, CID-neutral). Routes:
POST /:slug/blocks/{add, add-cond, add-each, :idx/{move,remove,cond}}.
Types-define-structure is the next layer (a type declares its composition field(s) + block
grammar). Full host conformance 399/399 (blog 170, incl. 5 new and/or/each tests: add-cond/
add-each/set-cond, a conditional rendering the context-chosen branch, the 3-form editor).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The add-block dropdown wrapped its <option>s in a <span> — (select :name "ctype" (span
(option…)…)) — to splice a dynamic list. A <select> only renders <option>/<optgroup> direct
children, so the dropdown was empty. A full-page load hid it (the browser's HTML parser hoists
mis-nested options out of the select), but on a BOOSTED nav the DOM is built programmatically
(no parser error-recovery), so the span stayed and the dropdown was empty. The card types are
a fixed set — inline the options directly as <select> children.
TEST-FIRST: 4th boost-nav.spec.js case (LOGGED IN: boosted nav to edit → assert
select[name=ctype] > option count is 5, incl card-heading). RED before (0 direct-child
options — span-wrapped), GREEN after. All 4 boost-nav tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The block-editor move/remove controls used :sx-disable "true" (the OLD relate-picker pattern
= plain POST → 303 → full reload). Switched to :sx-post + :sx-target #block-editor + :sx-swap
outerHTML (the current pattern): the click is a text/sx form round-trip through the WASM
engine, the handler returns the re-rendered #block-editor, and it swaps IN PLACE — no reload.
Added lib/host/playwright/{block-editor.spec.js, run-block-check.sh} (the run-picker-check
harness pattern: ephemeral host server + one editable post + the main worktree's chromium).
Verifies the irreducibly-browser behaviour the SX conformance can't see: adding, reordering
(↑), and removing blocks re-render #block-editor live, and the controls RE-BIND on the
content each swap brings in. PASSES (1/1, 16s). blog conformance still 165/165.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two concrete demonstrations of the composition architecture:
THIRD DOMAIN (proves step 8's "a new domain is just a dict + leaf, no new control flow").
host/comp-deps folds a composition to the object ids it TRANSCLUDES — the static contains
DAG of a body. It reuses host/comp-fold's seq/alt/each dispatch verbatim; only the leaf
(collect `(ref ID)`) + accumulator (concat) are new. Useful in its own right (what a
(seq (ref c0) (each … (ref …))) body pulls in; context-specific — alt picks the taken
branch). compose suite 20/20.
LIVE EXECUTE-FOLD DEMO (makes step 7 tangible, parallel to /compose-demo for render).
/workflow-demo runs ONE composition object's :body through host/exec-run — the SAME structure
the render-fold would turn into HTML, folded by execute into a plan of effects (validate →
branch on status → notify each recipient). host/blog-seed-workflow-demo! + host/blog-workflow-
demo + route + serve.sh seed. Shows the behaviour model IS an execute-fold over a composition
object — the same object the block editor authors. blog suite 165/165.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The post body is now editable as a composition. Model ops over the :body ref-seq (and the
ordered `contains` edges): host/blog-block-add! (create a card object is-a a card-type +
fields, contains edge, append a ref), -remove! (drop ref + edge), -move! (swap adjacent).
host/blog--block-editor renders a row per block — type + a content preview + ↑/↓/remove
controls + a "fields" link — plus an add-block form, injected into the edit page. Routes
POST /:slug/blocks/{add, :cslug/remove, :cslug/move} (guarded; SX-htmx sx-post + outerHTML
swap of #block-editor, redirect fallback for no-JS).
Cards-as-objects pays off: per-block FIELD editing is free — a card IS an object, so its
fields are edited via its own /<cslug>/edit page; the block editor only owns structure.
Guard fix: a card type is a SUBTYPE-OF card (not is-a), so the add validates ctype against
the down-closure of "card", not host/blog-is-a?. Verified via the warm server (162/164; the
2 fails are the pre-existing relate-picker pair). Deferred: Playwright live-swap check;
alt/each block insertion (the core editor handles the seq of refs).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A REAL production perf bug, surfaced while profiling slow conformance. host/blog--add-edge!
mirrored every edge into lib/relations via relations/relate, which RE-SATURATES the whole
CEK-interpreted Datalog ruleset on every single write — super-linear in the fact base
(profiled: 1.1s → 3.5s → 6.1s per edge as the graph grows 10→20→30 facts; O(graph) per
write, O(edges²) to build). This hit the LIVE SITE on every content op: importing a Ghost
post (decompose! = ~4 edges/block), tagging, relating, is-a, the metamodel editor — all
getting slower as the site grows.
Since typing now reads direct KV edges (host/blog--subtype-closure et al.), NOTHING in the
blog domain reads lib/relations anymore — the mirror was pure, very expensive dead weight.
So edges are now KV-only: add/del-edge! just kv-put/kv-delete (~20ms FLAT, O(1)); reads
already walk the edge:* rows directly. host/blog-load-edges! (which replayed every edge into
lib/relations on boot — O(edges²)) is now a no-op. conj/disj operands were already KV-only,
proving the whole graph can be. host/relations.sx (the relations DOMAIN service, its own
type:id nodes) is separate and untouched.
Result: blog-relate! 6.1s→20ms/call (and now FLAT, not growing); full blog suite ~23min→19s;
all 11 host suites 353/355 in 36s (the 2 fails are the pre-existing relate-picker pair). Live
writes drop from seconds to ~20ms. Pairs with the typing-reads-from-KV fix (prev commit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
STEP 5 (cards-as-objects). The importer no longer carries a Ghost body as one opaque
sx_content string: host/blog--decompose! splits an (article …) into one stored card OBJECT
per top-level block (is-a the mapped card-type + its field-values), links each by an ordered
`contains` edge, and sets the post :body = (seq (ref c0) (ref c1) …). Card types now carry a
render :template, so the new `ref` combinator (compose.sx) transcludes each card via the
SAME typed-block path articles use. /import wired to decompose; the home index filtered to
published so the "block"-status card objects stay hidden. Added the `val` leaf (raw field
value, no <span>) for attribute interpolation in templates (href/src). The post page renders
the transcluded cards — verified end-to-end (conformance 157/159; the 2 fails are the
pre-existing relate-picker pagination pair, unrelated).
PERF (the conformance-speed fix). host/blog typing — types-of / instances-of / type-defs —
computed the subtype closure via lib/relations descendants/ancestors, and EVERY such call
re-saturates the whole CEK-interpreted Datalog ruleset (~seconds each). Typing is the hottest
path (is-a?/types-of/instances-of run per post, per picker, per render), so this dominated
both the blog suite and live page latency. Now the closure is a host-side BFS over the DIRECT
subtype-of edges (the edge:* KV rows, via host/blog--subtype-closure) — one snapshot per
closure, O(edges), cycle-safe, Datalog-free. Same transitive set (KV == relations for direct
edges, host/blog-relate! writes both), so exact, not approximate. Drops Datalog out of the
typing hot path entirely — speeds conformance AND the live site (/tags etc.).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The render context is now the live EXECUTION environment: host/blog--comp-ctx reads device
(mobile/desktop from User-Agent) and locale (from Accept-Language) PURELY from the request
headers — no perform — alongside auth + the graph-query resolver. So the SAME composition
object renders responsively/personalised: `(alt (when (eq "device" "mobile") …) …)` is a
responsive layout, `(when (eq "locale" "fr") …)` a localised variant. The object (its
when-variants) is the definition; the context picks which path renders.
host/blog--device-of / host/blog--locale-of; comp-ctx now (principal req) — post handler
passes req; /compose-demo gains a device-variant block. Reactive/live values plug into the
same context later with no new combinators (the plan's "make the context live" axis).
Verified via focused harness eval (mobile+fr vs desktop+en contexts render M/D variants;
no-req ctx omits device). Tests added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>