SPEC-145
ID:SPEC-145Status:accepted

Composed runes

claude/v041-composed-runes-guide View source
  • draft 1
  • ready 1
  • done 8
Status flow: 1 ready, 2 done as of Oct 8 (dayly buckets)0123ready: 1done: 2Oct 8
Implemented by 14
Decisions 4
Related 56
Branches 12
History 49
  1. bcc1118
    Created (accepted)by bjornolofandersson
  2. c04815d
    Content editedby Claude
    feat(runes): report content no content-model field matches (WORK-631)
  3. 0214415
    Content editedby Claude
    plan: close v0.40.0; status notes; file WORK-631 and WORK-632
  4. 5ca3432
    Content editedby Claude
    test(content): character as a composed rune, against the SEO baseline (W
  5. 587518b
    Content editedby Claude
    feat(runes,transform): {% metablock %} places a declared meta block (WOR
  6. d24843c
    Content editedby Björn Andersson
    Merge pull request #682 from refrakt-md/claude/post-v0.38-roadmap-jb7pie
  7. 1f4aee2
    Content editedby Claude
    Composed runes: a rune defined by a composition template (WORK-622, WORK
  8. f64fc0c
    Content editedby Claude
    plan(SPEC-145): D27 — variant schema in a composition
  9. 0a79781
    Content editedby Claude
    plan: mark WORK-628 done
  10. a1e7a18
    Content editedby Claude
    spike(seo): run @adobe/structured-data-validator over the SEO baseline (
  11. b3823f6
    Content editedby Claude
    plan: draft v0.40.0 — The first composed rune
  12. 5fcd317
    Content editedby Claude
    plan(SPEC-145): record D26 — slot names, $each binding, construction-tim
  13. 5411d62
    Content editedby Claude
    docs(plan): SPEC-145 D25, schema follows where a definition is loaded fr
  14. 14eaddc
    Content editedby Claude
    docs(plan): SPEC-145 D2a, a composed rune gets a block-less engine confi
  15. de68ba1
    Content editedby Claude
    docs(plan): SPEC-145 D10c, a slot leaves no element, only its data-slot
  16. 05e38df
    Content editedby Claude
    docs(plan): SPEC-145 D10b, registration copies node-sourced values at tr
  17. 4ef0508
    Content editedby Claude
    docs(plan): SPEC-145 D10a, how the ownership marker reaches a placed nod
  18. 735101f
    Content editedby Claude
    plan: SPEC-145 D4 — why the include route cannot be hoisted off the page
  19. f843480
    Content editedby Claude
    plan: SPEC-145 D24 — carrier resolution descends through compositions
  20. 4c8809c
    Content editedby Claude
    plan: SPEC-145 D9 — the peer rule is relational, and a wrapper passes th
  21. cfa2000
    Content editedby Claude
    plan: SPEC-145 D4 — point the preprocessor-rune ban at its alternative
  22. e54df75
    Content editedby Claude
    plan: SPEC-145 authoring note — metablock versus a placed bar or deflist
  23. b80ad3e
    Content editedby Claude
    plan: SPEC-145 D22/D23 — routing channels, and where responsive variatio
  24. b87a113
    Content editedby Claude
    plan: SPEC-145 D21 — a metadatum's subject decides its owner, not its po
  25. 82372c6
    Content editedby Claude
    plan: rule mediatext out of D20, and make D18's "narrower second" precis
  26. 1a50722
    Content editedby Claude
    plan: SPEC-145 D20 — card owes its composers a placeable preamble
  27. f5abad4
    Content editedby Claude
    plan: add the `recipe` over `card` worked example to SPEC-145
  28. 02162b6
    Content editedby Claude
    plan: refine SPEC-145 — verify D14, reprice D13, collect the template vo
  29. 56fc3b6
    Content editedby Claude
    docs(plan): reject ADR-035; a composed rune is <rune>.md with the filena
  30. d3dfd24
    Content editedby Claude
    docs(plan): ADR-039 + SPEC-157 — where a rune lives, with all nine audit
  31. 4c42463
    Content editedby Claude
    docs(plan): SPEC-156 — the ladder and row arrangement primitives
  32. 2fe501b
    Content editedby Claude
    docs(plan): ADR-038 — a behavior binds on a data contract, never a BEM c
  33. 239e899
    Content editedby Claude
    docs(plan): SPEC-155 — media audit; playlist rebuilds where it could pro
  34. bd2cf7e
    Content editedby Claude
    docs(plan): SPEC-154 — learning audit, where the question becomes "build
  35. 99fb386
    Content editedby Claude
    docs(plan): SPEC-153 — delivering composed runes, and a dead mechanism n
  36. ee8bb0c
    Content editedby Claude
    docs(plan): SPEC-152 — plan audit; dedupe inside a plugin that stays
  37. c99f9c1
    Content editedby Claude
    docs(plan): SPEC-145 D18 — card is the media-split primitive; correct SP
  38. fc5bf1a
    Content editedby Claude
    docs(plan): SPEC-148 — places audit, per-property, plus the delimiter co
  39. 9a1940b
    Content editedby Claude
    docs(plan): SPEC-147 — replacing the storytelling plugin with composed r
  40. 26ab3e6
    Content editedby Claude
    docs(plan): SPEC-145 D15 — several templates per rune, corrected on both
  41. e6d767c
    Content editedby Claude
    docs(plan): SPEC-145 D12-D16 — three gaps found by testing composition a
  42. f7a9c28
    Content editedby Claude
    docs(plan): SPEC-145 D4 — a template renders at transform time, not prep
  43. 93e7dd4
    Content editedby Claude
    docs(plan): scope the composition SEO gap to node-sourced properties, wi
  44. ac2e1b3
    Content editedby Claude
    docs(plan): SPEC-146 — split name resolution across rune boundaries out
  45. b84ef20
    Content editedby Claude
    docs(plan): SPEC-145 D9-D11 — correct the SEO claim, and the three thing
  46. 861e539
    Content editedby Claude
    docs(plan): ADR-037 — users author composed runes only; the declared pat
  47. 1043286
    Content editedby Claude
    docs(plan): SPEC-145 D7 — the block placement tag is a placeholder, not
  48. ac4ca70
    Content editedby Claude
    docs(plan): SPEC-145 D7/D8 — meta blocks in composed runes, and no theme
  49. 080f74c
    Content editedby Claude
    docs(plan): SPEC-145 — worked examples for bond and character, plus four

Summary

A rune defined not by declaring its own output but by placing its authored content into slots of existing primitive runes, with a Markdoc template. It has no BEM block and ships no CSS: its appearance is whatever the installed theme already gives the primitives it is built from. It does carry its own identity — a data-rune, a schema.org row, and optionally a SPEC-144 registration — so the tree it produces means the domain type, not the primitives.

This is the second of two authoring tiers. SPEC-143 covers a genuinely new atom that needs its own block and styles; this covers a domain type that is structurally a familiar shape, which is most of them.

Background — why this is the pressure valve

A hosted refrakt cannot ship a plugin per domain, so users define runes. The temptation at every expressive gap is a richer language in the definition file; ADR-036 rules that out and names composition as the alternative. This spec is that alternative.

These are not two authoring tiers a user chooses between. They share their whole declaration half — a composed rune's frontmatter declares attributes, content, schema, metaFields, blocks and registers, which is SPEC-143's and SPEC-144's vocabulary in full. They differ only in what emits, and ADR-037 assigns the two emit paths to different audiences:

Declared emit path (SPEC-143)Composed emit path (this spec)
AudienceInternal — first-party runes shedding transformsUsers — the one way to author a rune outside this repo
OutputOwn block, own BEM classesThe primitives' blocks
CSSShips with the themeNone — inherits whatever the theme gives the primitives
Arrangement decided bythe theme, via layoutthe rune author, in the template
ContractDerived from config, as todayDerived by expanding the composition

The arrangement row is why the split lands this way. A declared rune hands arrangement to the theme, which is full ADR-028 portability and exactly what work, character and the rest need. A user running one site with one theme cannot cash that benefit, and pays for it in the CSS column: "a declaratively-defined rune renders unstyled" is the sharpest authoring barrier SPEC-143 identifies, and composition dissolves rather than solves it — there is no new block to style. Hence users get this path, and only this path.

The mechanism mostly exists

Two existing mechanisms supply most of it, and it matters which one does what.

AST substitution is a solved shape. {% include %} (packages/runes/src/tags/include.ts, SPEC-129) clones a file's parsed AST and substitutes variables bindings into it. That is the substitution technique a template needs — cloning a parsed tree and filling named holes — and it is already written.

But the timing is a rune's transform, not preprocess. include splices during preprocess so that data and snippet inside the pasted file get preprocessed; a composition cannot use that timing, because at preprocess the content model has not resolved and no slot has a value (D4). A template renders where transform would have, and calls Markdoc.transform on its result so the primitives inside it are transformed normally — exactly as character.ts already does with resolved.items. No new pipeline stage either way.

Three things are missing.

1. Content slots, not just scalar variables. variables={q: "rune:card"} binds strings. Composition needs to place authored subtrees into named positions — the body a user wrote under {% character %} has to land inside the template's {% card %}. That is substitution over node lists rather than over strings.

2. An outer identity. After the splice the tree is the primitives': it carries data-rune="card", and the cross-page pipeline, extractTitle, breadcrumbs and the editor all see a card. The composition needs a wrapper carrying the composed rune's own data-rune, with the inner runes demoted to implementation detail.

3. Cycle detection. A composes B composes A. Cheap to detect, easy to omit.

SEO — the retyping works, the name resolution does not

An earlier draft of this section claimed SEO was "the solved part" of composition. That was wrong in a way worth recording rather than quietly fixing, because the half-truth is seductive: the retyping mechanism does exist and does fit, but name resolution does not cross a composition boundary, and without that the retyping has nothing to retype. D9, D10 and D11 are what the corrected reading requires.

What does fit — retyping

applySchemaTable (packages/runes/src/lib/index.ts:463-500) retypes children from the parent's row, and says why in its own comments:

Children are retyped by the parent, never by themselves (D9). Markdoc transforms bottom-up, so by now the children carry their own typeof and this rewrites them — and only the parent can supply the property that nests them.

A parent's row.children is keyed by child name, and a SCHEMA_TYPE_EXPLICIT marker distinguishes a type the author stated — which survives — from one that came from the child's fallback row, which the parent may overrule, because "the parent is the better authority on what an unlabelled child is".

That is exactly composition's requirement. A composed character declares Person and child rows keyed by slot name; the card and deflist primitives it is built from contribute structure while the outer rune owns the claim. And the explicit marker means composition cannot silently steal a type an author stated by hand.

The identity problem is the same problem one layer up. The fix for SEO is already "the parent is the authority"; item 2 above is that principle applied to data-rune and cross-page registration rather than to typeof. Extending an existing principle is a much better position than inventing one.

What does not fit — reaching the values

A schema row maps names to schema.org properties, and two different resolvers turn a name into nodes. They behave oppositely, and neither is composition-ready:

ResolverUsed byBoundaryMatches
findAllByName (schema-table.ts:235)properties via stamp, text via findByNameStops at any nested data-runedata-name, data-field (+ kebab)
findChildren (:528)children — retyping child runesFull subtree, no guarddata-name, data-field, and data-rune

findAllByName's own doc comment describes the bug the guard exists to prevent, and it is precisely the bug composition would reintroduce:

The search stops at another rune's node. ADR-008's flat namespace is unique per rune, so the same name means different things in a parent and in a child it contains: character names its title span name, and so does every character-section inside it. Reaching across that boundary published a character whose name was the character plus each of its section headings.

So for properties, the problem is not ambiguity — it is unreachability, and it is confined to properties whose source is a node rather than an attribute. Take a table mapping a node, as castMemberSchema does ({ name: 'name', role: 'jobTitle', portrait: 'image' }), and a template that parks the portrait inside {% card %}:

<article data-rune="member" data-rune-fields='{"name":"Veshra"}'>
  <div data-rune="card">                                  <!-- boundary -->
    <img data-name="portrait" data-owner="member" src="v.jpg">
  </div>
</article>

findAllByName(root, 'portrait') visits the <article> (exempt, it is top), then hits div[data-rune="card"] and returns. Nothing inside the card is ever visited. stamp falls back to the field bag (:309-312), which holds no image because an image is a node and not a scalar. Measured against today's applier, with the same table over both tree shapes:

// composed — portrait slot-placed inside {% card %}
{ "@type": "Person", "name": "Veshra", "@context": "https://schema.org" }

// declared — portrait at the rune's own root
{ "@type": "Person", "image": "v.jpg", "name": "Veshra", "@context": "https://schema.org" }