Files
rose-ash/plans/composition-objects.md
giles ed68b9883d host: execute-fold — universality proven with a second fold (composition step 7)
The keystone validation of the universal-algebra thesis. lib/host/execute.sx is a SECOND
interpreter over the SAME seq/alt/each composition algebra as the render-fold — but a
different fold: leaves are EFFECTS, seq = steps in order, alt+when = branch, each =
for-each, and the accumulator is an effect log instead of an HTML string. It REUSES
compose.sx's shared machinery verbatim — host/comp--pred? (when), host/comp--field
(field/value), host/comp--source (each source) — so the predicate set, context-environment,
and iteration source are domain-agnostic; only the leaf semantics + accumulator are new.

KEYSTONE (tested): ONE (alt (when (has "auth") …) …) skeleton + ONE context folds two ways
— render picks the branch → "<b>in</b>", execute picks the SAME branch → {:verb "enter"}.
A publish workflow (validate → branch-on-status → notify-each) runs as one execute-fold over
a composition object. So the behaviour model (Slice 9) is "an execute-fold over a composition
object", not a separate system — the way the recursive tree proved recursion, this proves the
algebra is domain-agnostic. host/exec-run; 13/13 (new execute suite); wired into conformance
+ serve. Full host conformance 371/373 in 42s (warm); the 2 fails are the pre-existing
relate-picker pair.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 23:49:41 +00:00

10 KiB
Raw Blame History

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 strategyAlt isn't a new node kind, it's "first" instead of "all".

The two fundamentals we designed IN

  1. Recursion(tmpl NAME) may reference itself; (each (children) (tmpl NAME)) renders trees (comment threads, nested nav, the /meta type hierarchy itself). Terminates naturally when a query runs dry; a depth guard in the context backstops it.
  2. The context is an environment, not a flat dict. when reads it; each extends 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 live each re-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

  1. Keystone (this): lib/host/compose.sx — the render-fold interpreter over seq/row/alt/each/ ref/card/tmpl, with the context-as-environment, when predicates, and recursion + depth guard. Self-contained proof: render one composed object two ways (auth on/off) + a recursive tree.
  2. Wire it to objects: a document's :body is a composition node; contains forks carry order; host/blog-render dispatches to the render-fold when :body is present (else the legacy sx_content path). Card leaves render via the existing card-type :template.
  3. (done) each source = a graph query: (query is-a TYPE) resolves via a query resolver injected into the render context (host/blog--comp-ctx binds host/blog--comp-queryhost/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-demo each is now a live query over seeded compose-item instances.)
  4. (done) Live context: host/blog--comp-ctx routes 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.
  5. (done) The typed importer decomposes content into card OBJECTS + a contains body (cards-as-objects), instead of one sx_content string. host/blog--decompose! splits an (article …) into one stored card object per block (is-a a card-type + field-values), linked by ordered contains edges, with :body = (seq (ref c0) (ref c1) …). Card types carry a render :template, so the ref combinator transcludes each card via the existing typed-block path. /import wired; home filtered to published so "block" cards stay hidden. The val (raw value) leaf added for attribute interpolation. (Perf: typing now reads direct KV subtype-of edges via a host-side BFS, not lib/relations — no Datalog re-saturation.)
  6. (done, server-side) The block editor edits the body: host/blog-block-add! / -remove! / -move! operate on the :body ref-seq + ordered contains edges; host/blog--block-editor renders a row per block (type + preview + ↑/↓/remove + a link to edit the card's fields) + an add-block form, injected into the edit page; routes POST /: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/each block insertion deferred.)
  7. (done) Prove universality with a second fold. lib/host/execute.sx is an execute-fold over the same seq/alt/each structure: 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.
  8. Factor out the shared machinery once two folds exist: the fork model (ordered, labelled, when), the combinator dispatch, the context-environment, and recursion become a reusable compose core; each domain (render, execute, eval, …) supplies only its leaf + combinator semantics. The block editor + the metamodel UI then generalise to every fold — one composition editor authors documents, workflows, queries, and pipelines alike.