host: composition-objects render-fold — seq/par/alt/each + recursion + context (keystone)
Some checks failed
Test, Build, and Deploy / test-build-deploy (push) Failing after 35s

The cards-as-OBJECTS model (plans/composition-objects.md): an object's :body is a tiny UI
language over content-addressed object refs; the render-fold is its interpreter. Four
combinators — seq (sequence) / row,grid (layout/par) / alt+when (conditional/or) / each
(iteration/loop) — plus field/text/card leaves, ref (transclude), and tmpl (recursion).

The two fundamentals designed IN: (1) recursion via self-referential named templates
(tmpl) + each over (children) + a depth guard — renders trees (verified: a nested type
hierarchy -> [Types[Article][Card[Image][Callout]]]); (2) the context is an extensible
ENVIRONMENT —  reads it,  extends it (:item, :depth) — so behaviour (Slice 9)
and reactivity (signals) plug in via the context with no new combinators.

and/or/choice fall out of one axis ( on forks) x the container strategy (render-all
vs render-first), so Alt isn't a new node — it's 'first'. The unifying property, proven:
the object's CID is its DEFINITION (query/template/every when-variant); render is the
EXECUTION (which items/branch/context). One object renders two ways by context (anon ->
'Please log in', authed -> 'Members area'). Render-fold and the Slice-9 behaviour interpreter
are the same shape — interpreters over content-addressed objects.

lib/host/compose.sx is self-contained (no blog deps); verified via sx_eval (every combinator
+ a recursive tree + a full composed doc across two contexts). Roadmap: wire :body into
host/blog-render, each-source=graph-query, live context, Lexical->card-objects import, block
editor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-30 17:11:17 +00:00
parent 7d07ac7e4a
commit cdbb5bb4ba
2 changed files with 176 additions and 0 deletions

View File

@@ -0,0 +1,78 @@
# 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.
## 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.