Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 40s
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>
118 lines
8.1 KiB
Markdown
118 lines
8.1 KiB
Markdown
# 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.
|