Files
rose-ash/plans/composition-objects.md
giles b78491a5a1 host: block editor — edit the :body composition (composition roadmap step 6)
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>
2026-06-30 23:45:20 +00:00

9.9 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. Prove universality with a second fold. Write a tiny execute-fold over the same seq/alt/each structure that runs a workflow (leaves = effects; seq = steps in order, alt = branch, each = for-each) — the way the recursive tree proved recursion, this proves the composition algebra is domain-agnostic. Then the behaviour model (Slice 9) is "an execute-fold over a composition object", not a separate system.
  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.