diff --git a/lib/host/compose.sx b/lib/host/compose.sx
new file mode 100644
index 00000000..507dd72c
--- /dev/null
+++ b/lib/host/compose.sx
@@ -0,0 +1,98 @@
+;; lib/host/compose.sx — the composition / object render-fold (plans/composition-objects.md).
+;;
+;; An object's :body is a composition node — a tiny UI language over object refs. The
+;; render-fold below is its interpreter. Four combinators (seq/row/alt/each) + leaves
+;; (field/text/card) + ref + recursion (tmpl). The context is an EXTENSIBLE ENVIRONMENT:
+;; `when` reads it, `each` extends it (:item, :depth). Same predicate set as the type
+;; guards. The object's CID is its DEFINITION; render is the EXECUTION (per context+data).
+;; Self-contained (no blog deps) so the model can be proven in isolation.
+
+;; ── predicates for `when` (over the context environment) ────────────
+(define host/comp--pred?
+ (fn (pred ctx)
+ (let ((op (str (first pred))))
+ (cond
+ ((= op "has") (not (nil? (get ctx (str (first (rest pred)))))))
+ ((= op "eq") (= (str (get ctx (str (first (rest pred))))) (str (first (rest (rest pred))))))
+ ((= op "not") (not (host/comp--pred? (first (rest pred)) ctx)))
+ (else false)))))
+
+;; the value of a leaf (field): the current :item's key, else the context's key.
+(define host/comp--field
+ (fn (k ctx)
+ (let ((item (get ctx "item")) (key (str k)))
+ (if (and item (not (nil? (get item key))))
+ (str (get item key))
+ (str (or (get ctx key) ""))))))
+
+;; the source collection for `each`: literal items, the :item's :children (trees), or a
+;; named list field on the :item. (A graph-query source is wiring step 3, plan roadmap.)
+(define host/comp--source
+ (fn (src ctx)
+ (let ((op (str (first src))) (item (get ctx "item")))
+ (cond
+ ((= op "items") (rest src))
+ ((= op "children") (if item (or (get item "children") (list)) (list)))
+ ((= op "field") (if item (or (get item (str (first (rest src)))) (list)) (list)))
+ (else (list))))))
+
+;; ── template registry (recursion: a template may reference itself by name) ──
+(define host/comp--tmpls (dict))
+(define host/comp--def-tmpl! (fn (name node) (dict-set! host/comp--tmpls name node)))
+
+;; ── the render-fold (the interpreter) ───────────────────────────────
+(define host/comp--render-all
+ (fn (nodes ctx) (reduce (fn (acc n) (str acc (host/comp--render n ctx))) "" nodes)))
+
+;; alt: render the FIRST branch whose `when` holds (or `else`) — recursive first-match so
+;; a branch that legitimately renders empty isn't skipped.
+(define host/comp--alt-pick
+ (fn (branches ctx)
+ (if (empty? branches)
+ ""
+ (let ((br (first branches)) (bh (str (first (first branches)))))
+ (cond
+ ((= bh "else") (host/comp--render (first (rest br)) ctx))
+ ((= bh "when") (if (host/comp--pred? (first (rest br)) ctx)
+ (host/comp--render (first (rest (rest br))) ctx)
+ (host/comp--alt-pick (rest branches) ctx)))
+ (else (host/comp--alt-pick (rest branches) ctx)))))))
+
+;; each: eval source -> items; render template per item with :item bound + :depth+1
+;; (depth guard backstops runaway recursion; trees terminate naturally on empty source).
+(define host/comp--each
+ (fn (src tmpl ctx)
+ (let ((depth (or (get ctx "depth") 0)))
+ (if (> depth 40)
+ "(max depth)"
+ (reduce
+ (fn (acc item)
+ (str acc (host/comp--render tmpl (merge ctx {"item" item "depth" (+ depth 1)}))))
+ "" (host/comp--source src ctx))))))
+
+;; card leaf (proof: a labelled box; in the host this renders via the card-type's :template).
+(define host/comp--card
+ (fn (ctype fields)
+ (str "
"
+ (reduce (fn (acc k) (str acc "" k ": " (str (get fields k)) " ")) "" (keys fields))
+ "
")))
+
+(define host/comp--render
+ (fn (node ctx)
+ (if (not (= (type-of node) "list"))
+ (str node)
+ (let ((h (str (first node))) (args (rest node)))
+ (cond
+ ((= h "seq") (host/comp--render-all args ctx))
+ ((= h "row") (str "" (host/comp--render-all args ctx) "
"))
+ ((= h "grid") (str "" (host/comp--render-all args ctx) "
"))
+ ((= h "alt") (host/comp--alt-pick args ctx))
+ ((= h "each") (host/comp--each (first args) (first (rest args)) ctx))
+ ((= h "field") (str "" (host/comp--field (first args) ctx) ""))
+ ((= h "text") (str (first args)))
+ ((= h "card") (host/comp--card (str (first args)) (first (rest args))))
+ ((= h "tmpl") (host/comp--render (get host/comp--tmpls (str (first args))) ctx))
+ (else ""))))))
+
+;; public entry: render a composition node against a context environment.
+(define host/comp-render (fn (node ctx) (host/comp--render node ctx)))
diff --git a/plans/composition-objects.md b/plans/composition-objects.md
new file mode 100644
index 00000000..726f7c3a
--- /dev/null
+++ b/plans/composition-objects.md
@@ -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.