Acceptance Criteria
- A rune can be defined by a Markdoc template that places named slots into other runes
- Authored content reaches a named slot as a node list, not a string
- The template renders at the rune's transform, after field resolution, at the call site SPEC-143 substitutes at — not at preprocess (D4)
- Runes placed by a template are transformed normally, verified by composing from a behaviour-driven primitive and confirming its markup and behavior binding are unchanged (D4)
emitAttributes' existing $heading, $field and '$a|$b' forms all work from a composition template, asserted per form (D4a){% slot name="x" %}…{% /slot %} renders its fallback content when x is emptysections is placed with an ordinary {% slot name="sections" each %}; no special form exists (D26)- The emitted tree carries the composed rune's
data-rune; the primitives' markers remain but do not claim the rune's identity (D3) - A composed rune has a generated, block-less
RuneConfig: its root carries no rf-* class and no data-rune-fields, and its modifiers, universal attributes and meta blocks render (D2a) - Slot substitution sets the ownership marker SPEC-146 defines, on both slot-placed and template-placed nodes (D10)
- A slot emits no element; every top-level node it places carries
data-slot in the rendered HTML, data-owner does not, and the composed rune's contract lists its slot names (D10c) - A composed rune whose
registers source is a placed node registers the same id and data as one whose source is an attribute, read from the field bag in Phase 2; findRef and the strip timing are unchanged (D10b) - An author's nested rune inside a slot is never retyped as part of the composed entity (D10)
- A composed rune nested inside another cannot reach the inner one's names, and vice versa (D10)
extractTitle, breadcrumb resolution and the cross-page registry read the composed rune, not its primitives- A plugin rune entry carrying a template and no
transform passes validatePlugin, and one carrying both is rejected naming the rune (SPEC-153 D11) refrakt contracts derives a composed rune's structure by expansion (D6)
Resolution
Completed: 2026-10-08
Branch: claude/v040-composition-templates (#683); claude/v040-metablock (#685) PR: refrakt-md/refrakt#683, refrakt-md/refrakt#685
What was done
- #683 built the mechanism. Its PR description has the full record:
packages/runes/src/lib/composition.ts: the template vocabulary (slot with fallback and each, $each, $attrs, Markdoc's if), rendered at the rune's transform after field resolution; data-owner / data-slot markers; D10b's copy of node-sourced registers values into the field bag; the generated block-less config.packages/runes/src/composed-rune.ts: defineComposedRune and composedPluginRune.PluginRune.template, with validatePlugin accepting exactly one emit path.- Contracts list a composed rune's slot names and its expansion.
- #685 (WORK-630) closed the remaining criterion: meta blocks render.
- A template places a declared block with
{% metablock %}, which the engine fills through the same block renderer layout uses. - Attributes a
metaFields entry reads are modifiers of the generated config. composition-metablock.test.ts asserts the whole D2a criterion together: the root has no rf-* class and no data-rune-fields, and its modifiers, universal attributes and meta block render.
Findings (from #683; recorded there in full)
- Template-placed nodes get
data-owner only, not data-slot, and only at the template's top level. Fallback content is the template's own. {% slot name="x" each %} is not valid Markdoc; each is normalised to each=true inside slot tags.- D12's parent check compares
data-rune, not the tag name. extractTitle exists only as the plan plugin's private helper. The criterion is asserted on the shared consumers: outer data-rune, the registers participant, breadcrumb auto, and JSON-LD.- The editor's community-tags bundle reads
entry.transform only. The editor half is deferred. - D10a amendment: a node carrying both markers has two namespaces.