The roadmap's capstone: now that two folds exist (render, execute), extract the machinery
they share. host/comp-fold (compose.sx) is the reusable core — the seq/alt/each combinator
dispatch + the `when` predicate set (host/comp--pred?) + the context-environment + the `each`
source (host/comp--source) + recursion + the depth guard, ALL in one place. A domain plugs in
via a small dict {:empty :combine :leaf :overflow}; only its leaves and how results combine
differ:
render = {:empty "" :combine str …} leaf -> markup (+ row/grid layout combinators)
execute = {:empty (list) :combine concat …} leaf -> effect
host/comp-render and host/exec-run are now one-liners over host/comp-fold with their domain.
execute.sx shed its own seq/alt/each dispatch — it's just a dict + a leaf. A THIRD domain
(eval/reduce/extent over the same algebra) is now only a new dict + leaf, no new control flow.
Both folds went through the core with ZERO behaviour change: new tests/compose.sx exercises
the core + render domain directly (17/17 — leaves, seq, row, alt+when (has/eq/not), each
(items/query/empty), tmpl recursion over a (children) tree + depth guard, ref transclude, one
object two contexts); execute 13/13; blog 162/164 (2 pre-existing relate-picker fails). Full
host conformance 388/390. Wired tests/compose.sx into conformance.
plans/composition-objects.md roadmap steps 1-8 COMPLETE.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
11 KiB
Composition objects — a content-addressed, data-driven UI model
Everything the system stores is an object: typed, content-addressed (:cid), in one graph.
"Post" was the blog's word; the unit is an object. A document is an object whose body is a
composition over other objects' CIDs. This is the cards-as-objects decision, generalised.
One mechanism: ordered, labelled forks
An object forks into children via labelled, ordered edges (the relations engine + order on
the edge value + an optional when). There is no separate "composition system" — relations are
the forks. The label says what a fork means:
- structural (
contains) → ordered, part of identity, rendered; - cross-cutting (
tagged,related,author) → loose links, not structural.
Multiple relations from an object are its fork. No "multiple DAGs per object" — fork immediately;
differently-labelled forks (body vs aside) give named slots. Join = a child CID referenced
by two forks — free, because content-addressed. The whole structure is a Merkle DAG (git trees
/ IPFS / artdag): :cid = hash over fields + contains-forks (child-CID + order + when).
The body is a tiny UI language (the render-fold is its interpreter)
A body is a composition node. Four combinators + leaves + references:
| node | meaning | strategy |
|---|---|---|
(seq …) |
sequence | render all (block), in order |
(row …) / (grid …) |
layout (par) | render all, side-by-side |
(alt (when P n) … (else n)) |
conditional (or) | render the FIRST child whose when holds |
(each src tmpl) |
iteration (loop) | eval src → items; render tmpl per item (item bound) |
(ref CID) |
transclude | fetch object by CID, render its body |
(card TYPE fields) |
leaf | render via the card-type's :template (host/blog--instantiate) |
(tmpl NAME) |
recursion | a named template, may reference itself |
seq/row = render-all passing children; alt = render-first passing child. So and/or/choice
all come from one axis (when on forks) × the container's all/first strategy — Alt isn't a new
node kind, it's "first" instead of "all".
The two fundamentals we designed IN
- Recursion —
(tmpl NAME)may reference itself;(each (children) (tmpl NAME))renders trees (comment threads, nested nav, the/metatype hierarchy itself). Terminates naturally when a query runs dry; a depth guard in the context backstops it. - The context is an environment, not a flat dict.
whenreads it;eachextends it (:item). Make it extensible + reactive-ready and the two non-composition axes plug in with NO new combinators:- Behaviour / interactivity (Slice 9 lifecycles/effects) — a button references a behaviour;
- Reactivity / local state (the reactive runtime) —
alt(when local-state=active-tab)is a tabset,alt(when accordion-open)an accordion; a liveeachre-renders on data change. The static render-fold becomes a live, interactive UI purely by making the context live.
The unifying property
The object's CID is its definition (the query, the template, every when-variant). The
rendering is the execution (which items, which branch, which context). The object is the
program; the render is the run. One immutable content-addressed object encodes its whole
responsive/personalised/variant space; rendering picks the path. Render-fold and the Slice-9
behaviour interpreter are the same shape — interpreters over content-addressed objects + the
decidable-core predicate set + the graph. The system converges on: objects + small interpreters.
Beyond content — composition is universal; a fold per domain
The render-fold isn't "the content renderer" — it's fold #1. The composition DAG is a
universal algebra (seq/par/alt/each over content-addressed objects); content is just one
interpretation. Same structure, a different fold per domain — what changes is what the
combinators and leaves mean:
| domain | the fold | seq |
par |
alt+when |
each |
substrate |
|---|---|---|---|---|---|---|
| content | render → HTML | block order | layout/columns | choose variant | map items | compose.sx (done) |
| behaviour | execute → effects | steps in order | concurrent | branch (if/cond) | for-each | [[project_flow_on_sx]] |
| query | eval → results | join/chain | union | conditional | iterate/quantify | [[project_relations_on_sx]] (Datalog) |
| pipeline | reduce → data | dataflow stages | parallel ops | choose path | fan-out | [[project_artdag_on_sx]] (content-addressed DAG) |
| types | extent → set | — | ∧ intersection | — | ∨ union | the type algebra (make-and!/make-or!) |
So "relations just a fork" generalises: a contains fork folded by render is a document; a
then fork folded by execute is a workflow step; a depends-on fork folded by eval is a
dependency graph. The relation kind + the fold = the domain. This isn't aspirational — the
repo's X-on-sx loops ALREADY ARE these folds (flow = execute, Datalog = eval, artdag = a
content-addressed composition DAG); we just hadn't seen them as one shape. The composition DAG is
the convergence point the whole fleet has been circling.
The payoff is concrete: build the composition machinery ONCE (forks + ordered edges + the four
combinators + a fold framework) → reuse for every domain by writing one interpreter. The block
editor edits any composition — author a workflow like a document, same structure, one editor.
The whole system collapses to four ideas: content-addressed objects + a composition algebra +
per-domain folds + the decidable-core predicates (when). The render-fold's shape (walk the
composition, dispatch combinators, recurse, read the context) is the template for every other fold.
What lives elsewhere (not composition primitives)
Transclusion = a ref leaf. Sort/filter/limit/group = the source query language (Datalog).
each reconciliation keys = the item's CID (free). Empty / missing-CID = render-fold robustness
(the per-block guard). Async/streaming, events, local state = the behaviour + reactive axes.
Build roadmap
- Keystone (this):
lib/host/compose.sx— the render-fold interpreter over seq/row/alt/each/ ref/card/tmpl, with the context-as-environment,whenpredicates, and recursion + depth guard. Self-contained proof: render one composed object two ways (auth on/off) + a recursive tree. - Wire it to objects: a document's
:bodyis a composition node;containsforks carry order;host/blog-renderdispatches to the render-fold when:bodyis present (else the legacysx_contentpath). Card leaves render via the existing card-type:template. - (done)
eachsource = a graph query:(query is-a TYPE)resolves via aqueryresolver injected into the render context (host/blog--comp-ctxbindshost/blog--comp-query→host/blog-instances-of→ records). compose.sx stays self-contained — it asks the context for the data; the host supplies graph access. The list isn't baked into the body; it's whatever is-a TYPE right now. (/compose-demoeach is now a live query over seededcompose-iteminstances.) - (done) Live context:
host/blog--comp-ctxroutes auth + device (User-Agent) + locale (Accept-Language) — read purely from the request — into the render context, so the SAME object renders a responsive/personalised variant ((alt (when (eq "device" "mobile") …) …)). Reactive values plug into the same context later with no new combinators. - (done) The typed importer decomposes content into card OBJECTS + a
containsbody (cards-as-objects), instead of onesx_contentstring.host/blog--decompose!splits an(article …)into one stored card object per block (is-a a card-type + field-values), linked by orderedcontainsedges, with:body = (seq (ref c0) (ref c1) …). Card types carry a render:template, so therefcombinator transcludes each card via the existing typed-block path./importwired; home filtered to published so"block"cards stay hidden. Theval(raw value) leaf added for attribute interpolation. (Perf: typing now reads direct KVsubtype-ofedges via a host-side BFS, not lib/relations — no Datalog re-saturation.) - (done, server-side) The block editor edits the body:
host/blog-block-add!/-remove!/-move!operate on the:bodyref-seq + orderedcontainsedges;host/blog--block-editorrenders a row per block (type + preview + ↑/↓/remove + a link to edit the card's fields) + an add-block form, injected into the edit page; routesPOST /:slug/blocks/{add,:cslug/remove,:cslug/move}(guarded, SX-htmx outerHTML swap). Per-block field editing is free — a card is an object, edited via its own/<cslug>/edit. (Live SX-htmx swap still wants a Playwright check;alt/eachblock insertion deferred.) - (done) Prove universality with a second fold.
lib/host/execute.sxis anexecute-fold over the sameseq/alt/eachstructure: leaves = effects,seq= steps in order,alt+when= branch,each= for-each; the fold returns an effect log. It REUSES compose.sx's shared machinery —host/comp--pred?(when),host/comp--field(field/value),host/comp--source(each source) — so only the leaf semantics + accumulator differ. KEYSTONE proven (tests): ONE(alt (when …) …)skeleton + ONE context folds two ways — render picks the branch → HTML, execute picks the SAME branch → effect. A publish workflow (validate→branch→notify-each) runs as one execute-fold. The behaviour model (Slice 9) is "an execute-fold over a composition object", not a separate system. 13/13 (execute suite). Wired into conformance + serve. - (done) Factor out the shared machinery.
host/comp-fold(compose.sx) is the reusable core: the seq/alt/each combinator dispatch + thewhenpredicate set + the context-environment- the
eachsource + recursion + the depth guard, ALL in one place. A domain plugs in via a dict{:empty :combine :leaf :overflow}— only its leaves and how results combine. render ={:empty "" :combine str …}(leaf → markup, + row/grid layout combinators); execute ={:empty (list) :combine concat …}(leaf → effect). Both folds went through the core with zero behaviour change (compose suite 17/17, execute 13/13, blog 162/164 — the 2 fails pre-existing). A third domain (eval/reduce/extent) is now just a new dict + leaf. The block editor + metamodel UI generalise to every fold — one composition editor for documents, workflows, queries, pipelines alike.
- the
Status: roadmap COMPLETE (steps 1-8). Remaining polish: Playwright live-swap check for the
block editor; alt/each block insertion in the editor; a live workflow object executed via the
execute-fold (the way /compose-demo shows the render-fold); a third domain to exercise the core.