Files
rose-ash/plans/composition-objects.md
giles 1c2bf505f4
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
plan: composition is universal — a fold per domain (render/execute/eval/reduce/extent)
The composition DAG is not a content mechanism; render is fold #1. The same structure
(content-addressed objects + ordered labelled forks + seq/par/alt/each) is interpreted by a
different fold per domain: content=render, behaviour=execute (flow-on-sx), query=eval
(Datalog), pipeline=reduce (artdag, literally a content-addressed composition DAG),
types=extent (and/or = intersection/union). "Relations just a fork" generalises: relation
kind + fold = domain. The X-on-sx loops already ARE these folds — the composition DAG is the
fleet convergence point. Payoff: build composition once, reuse per domain via interpreters;
the block editor + metamodel UI generalise to every fold (author a workflow like a document).
System collapses to four ideas: content-addressed objects + composition algebra + per-domain
folds + decidable-core predicates. Roadmap +2: prove universality with a second (execute)
fold over the same seq/alt/each; then factor out the shared compose core.

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

118 lines
8.1 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
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. `each` source = a graph query (`(query is-a Event)``host/blog-instances-of`) — data-driven.
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.