Files
rose-ash/plans/composition-objects.md
giles ed68b9883d host: execute-fold — universality proven with a second fold (composition step 7)
The keystone validation of the universal-algebra thesis. lib/host/execute.sx is a SECOND
interpreter over the SAME seq/alt/each composition algebra as the render-fold — but a
different fold: leaves are EFFECTS, seq = steps in order, alt+when = branch, each =
for-each, and the accumulator is an effect log instead of an HTML string. It REUSES
compose.sx's shared machinery verbatim — host/comp--pred? (when), host/comp--field
(field/value), host/comp--source (each source) — so the predicate set, context-environment,
and iteration source are domain-agnostic; only the leaf semantics + accumulator are new.

KEYSTONE (tested): ONE (alt (when (has "auth") …) …) skeleton + ONE context folds two ways
— render picks the branch → "<b>in</b>", execute picks the SAME branch → {:verb "enter"}.
A publish workflow (validate → branch-on-status → notify-each) runs as one execute-fold over
a composition object. So the behaviour model (Slice 9) is "an execute-fold over a composition
object", not a separate system — the way the recursive tree proved recursion, this proves the
algebra is domain-agnostic. host/exec-run; 13/13 (new execute suite); wired into conformance
+ serve. Full host conformance 371/373 in 42s (warm); the 2 fails are the pre-existing
relate-picker pair.

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

142 lines
10 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. **(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-query``host/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. **(done)** Live context: `host/blog--comp-ctx` routes auth + device (User-Agent) + locale
(Accept-Language) — read purely from the request — into the render context, so the SAME
object renders a responsive/personalised variant (`(alt (when (eq "device" "mobile") …) …)`).
Reactive values plug into the same context later with no new combinators.
5. **(done)** The typed importer decomposes content into card OBJECTS + a `contains` body
(cards-as-objects), instead of one `sx_content` string. `host/blog--decompose!` splits an
`(article …)` into one stored card object per block (is-a a card-type + field-values),
linked by ordered `contains` edges, with `:body = (seq (ref c0) (ref c1) …)`. Card types
carry a render `:template`, so the `ref` combinator transcludes each card via the existing
typed-block path. `/import` wired; home filtered to published so `"block"` cards stay hidden.
The `val` (raw value) leaf added for attribute interpolation. (Perf: typing now reads direct
KV `subtype-of` edges via a host-side BFS, not lib/relations — no Datalog re-saturation.)
6. **(done, server-side)** The block editor edits the body: `host/blog-block-add!` /
`-remove!` / `-move!` operate on the `:body` ref-seq + ordered `contains` edges;
`host/blog--block-editor` renders a row per block (type + preview + ↑/↓/remove + a link
to edit the card's fields) + an add-block form, injected into the edit page; routes
`POST /:slug/blocks/{add,:cslug/remove,:cslug/move}` (guarded, SX-htmx outerHTML swap).
Per-block field editing is free — a card is an object, edited via its own `/<cslug>/edit`.
(Live SX-htmx swap still wants a Playwright check; `alt`/`each` block insertion deferred.)
7. **(done)** Prove universality with a second fold. `lib/host/execute.sx` is an `execute`-fold
over the *same* `seq/alt/each` structure: leaves = effects, `seq` = steps in order, `alt`+`when`
= branch, `each` = for-each; the fold returns an effect log. It REUSES compose.sx's shared
machinery — `host/comp--pred?` (when), `host/comp--field` (field/value), `host/comp--source`
(each source) — so only the leaf semantics + accumulator differ. KEYSTONE proven (tests): ONE
`(alt (when …) …)` skeleton + ONE context folds two ways — render picks the branch → HTML,
execute picks the SAME branch → effect. A publish workflow (validate→branch→notify-each) runs as
one execute-fold. The behaviour model (Slice 9) is "an execute-fold over a composition object",
not a separate system. 13/13 (execute suite). Wired into conformance + serve.
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.