Files
rose-ash/plans/composition-objects.md
giles 29aa7cd70f host: each-source = graph query — the data-driven each (composition roadmap step 3)
An object's `each` source can now be a GRAPH QUERY: `(query is-a TYPE)` resolves to
whatever is-a TYPE *right now* — the list isn't baked into the body, it's the live graph.
The object's `each` IS the query; the render is the run over current data (the unifying
property, now over real data).

compose.sx stays self-contained: the `query` source delegates to a resolver bound in the
render context under "query" — it asks the context for data, never reaching into the graph
itself. The host supplies graph access via host/blog--comp-query (`(query is-a TYPE)` ->
host/blog-instances-of -> full records) injected by host/blog--comp-ctx (auth + resolver);
the post handler renders :body against that context.

Added a `val` leaf — the raw field value with no markup wrapper, for use inside attributes
(href/src). `field` stays span-wrapped for display; `(val :slug)` makes a real link in the
each template. /compose-demo's each is now a live (query is-a compose-item) over two seeded
instances instead of a baked literal list.

Verified end-to-end via a focused harness eval over the full relations+persist+blog stack
(query iterates real instances; clean href via val; empty query -> empty, not an error).
Blog suite 151/153 — the 2 fails ("relate-options load-more sentinel", "related picker
offers all posts") are PRE-EXISTING (clean HEAD is 149/151 with the identical 2 fails, a
relate-picker pagination-boundary issue) and unrelated to composition; my 2 new tests pass.

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

8.5 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. Live context: route auth/device/locale into the context; reactive values later.
  5. The typed importer decomposes Ghost Lexical into card objects + a contains body (cards-as- objects), instead of one sx_content string.
  6. The block editor edits the body (insert/reorder/alt/each) — the metamodel editor for content.
  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.