v0.41.0

Released October 9, 2026 · All releases

Composed runes you can ship. v0.40.0 let a rune be defined as a template that places existing runes, but only from inside this repository. Now you can write your own: put a <rune>.md in your project's runes/ directory, or ship a directory of them from a plugin by declaring runeDir. The composed runes guide covers the file format, where definitions go, and every error a definition can produce.

warning

Action needed: content-unmatched is now an error. Content inside a rune that no part of its content model reads used to be dropped without a word. It now fails refrakt validate and the build, naming the rune, the node and its line. The content never rendered, so the fix belongs in the rune: give it a field that takes the content, or set rawBody: true if its transform reads the raw source. To unblock a build meanwhile, add "content-unmatched" to validation.disableIds.

Plugin authors: a by schema table now needs a row for every value in its attribute's matches, or the rune fails to build.

Background: · ·

Changes

  • A project can define its own composed runes (SPEC-153 D4). Every <rune>.md in runes.dir is a composed rune named after its file. runes.dir is relative to the project root and defaults to runes.
  • Read through ProjectFiles (D5). A hosted build passes projectFiles: memoryProjectFiles(map) to createVirtualRefraktLoader and gets the project's runes with no filesystem. A path that escapes the project root is refused by the provider.
  • Precedence is core < plugin < project (D8). A project rune taking a core rune's name or alias is rejected, naming both. Taking a plugin rune's name needs runes.prefer: "__project__" lets the project's definition win, and the plugin's name keeps the plugin's rune.
  • A project definition may not declare schema (SPEC-145 D25). It is rejected at load, naming the rune. The same definition in a plugin's runeDir is accepted.
  • runes.local keeps its scope (D7). A .md path, or a module exporting a composition template, is rejected with a pointer to runes.dir.
  • In dev, editing a definition rebuilds every page that uses it (D10). setupContentHmr takes { runesDir }, and the SvelteKit, Astro and Nuxt integrations pass it. createRefraktLoader rebuilds its rune set when a definition changed.
  • refrakt validate reports each definition that does not build as a finding with its file and line (rune-definition-invalid). A site-assembly failure, such as a rune name collision, is reported as a finding rather than a crash. Construction errors now carry a line (CompositionError).
  • Everything else that loads runes reads the directory too: inspect, reference and contracts, the SvelteKit build, the HTML starter's build script and the language server.
  • create-refrakt writes runes: { dir: "runes" } into the configs it scaffolds.
  • A plugin can ship composed runes as files (SPEC-153 D2). Declare runeDir: 'runes' on the Plugin export, and every <rune>.md in that package-relative directory loads as a composed rune named after the file. It behaves exactly like a runes entry carrying the file as its template, so inspect, reference, contracts and the pipeline show it the same way.
  • A frontmatter key restating the rune's name is rejected, and so is a file name that is not a kebab-case rune name.
  • A directory rune and a code-defined rune with the same name are rejected, naming both.
  • A missing, unreadable or empty rune directory throws with the resolved path and the cause, instead of loading no runes. So does a package whose exports map blocks ./package.json.
  • refrakt plugins validate checks that files publishes the directory and that <pkg>/package.json resolves, and builds every definition in it. A composed rune's description frontmatter now counts as its description there.
  • loadPlugin(name, { from }) resolves the package from another location, for tools and tests.
  • A plugin's fixtures/ files that fail to parse now throw from loadPlugin, as discoverPluginFixtureManifest documented. Before, the error was swallowed along with an unresolvable package.
  • Validation now reports content that no content-model field matches (content-unmatched). A rune used to drop such a node from the page silently: a block written before the first section of a rune whose preamble does not take it, a node left after the last sequence field, or a --- zone the rune does not read. Each one is now a warning finding in refrakt validate, the refrakt.validate MCP tool and the build summary. The finding names the rune, the dropped node and its line. A custom content model is never reported, and neither is a rune that reads its body as raw source. createContentModelSchema takes a new rawBody: true option to declare that, and deferBody implies it. Turn the check off with validation.disableIds: ["content-unmatched"].
  • content-unmatched is now an error. Content inside a rune that no field of its content model matches was reported as a warning since it was introduced; it is now reported at error severity, so refrakt validate exits non-zero and the build summary lists it as an error.
  • This is a behaviour change for any project whose content trips the check. Each finding names the rune, the dropped node and its line. That content is not rendered — it never was — so the fix is in the rune, not the page:
  • add a field that takes it, for example a catch-all { name: 'body', match: 'any', optional: true, greedy: true } at the end of the position where authors write it;
  • or, for a rune whose transform reads its body as raw source rather than through the content model, set rawBody: true on createContentModelSchema.
  • To unblock a build while you fix a rune, add "content-unmatched" to validation.disableIds in the site config, which demotes the finding to info.
  • The plan runes keep everything authors write. They were the reason the check shipped as a warning — 800 nodes dropped over this repository's own plan/ directory:
  • milestone keeps its ## sections. Everything after the lead paragraphs — the goals list, notes and every section — renders in the body, in authored order.
  • work, bug and decision keep preamble content that is not a paragraph: a > Ref: blockquote, a list, a fence, a table or a --- before the first ##. It renders in a new intro region between the metadata and the sections, omitted when empty. Their layout gains the intro slot, and Lumina styles it like the body.
  • Every construction error a composed rune definition can produce now has a stable code. CompositionError.code names it, and COMPOSITION_ERRORS lists every code with a one-line summary. The new authoring guide, extend/rune-authoring/composed-runes, documents each code with its cause and fix, and a test fails if an error is added without an entry.
  • A template with an unclosed or unopened tag, such as {% hint %} with no {% /hint %}, is now rejected with template-parse. Before, Markdoc recorded the problem on the node and the definition built anyway.
  • A by schema table's rows must now match its attribute's matches exactly, both ways (SPEC-145 D27 (c), WORK-632). Until now validateSchemaTable checked only that by named a declared attribute and that a fallback existed. A misspelt row (podcasts:) or a newly added enum value would silently select the fallback, so {% playlist type="podcast" %} could publish MusicAlbum.
  • The table is rejected when the rune is built, with the selecting attribute named in the error, if:
  • a row key is not among the attribute's matches. The error names the key and the declared values;
  • a value in matches has no row. This is an error, not a fall-through to fallback. fallback covers an author who stated no value. A value the rune declares needs its own row, even when that row repeats the fallback;
  • the attribute has no matches list, so its rows cannot be checked.
  • validateSchemaTable now accepts the attribute definitions as well as a list of names. With names only, it skips the coverage check. Every shipped table already passes (playlist, track, organization), so existing output is unchanged. A third-party plugin whose table leaves a declared value uncovered will now fail to build until it adds the row.

v0.41.0 — Composed runes you can ship

v0.41.0complete

v0.41.0 — Composed runes you can ship

v0.40.0 built composition and proved it on bond and character. Nobody outside the repo can use it yet: a definition reaches the pipeline only as a string passed to defineComposedRune. This milestone closes that gap. A plugin ships definitions from a declared directory, a project writes its own in runes.dir, and the authoring guide makes it a documented feature (SPEC-153, SPEC-145).

It also takes the storytelling replacement (SPEC-147) from two runes to five, leaving only the two that need new primitives.

Why

Composition only pays off once people can write definitions. Until it does, its value to anyone outside the repo is zero, however well it is tested. The hosted product is the case SPEC-153 was written for, and it needs the project path to work under memoryProjectFiles.

The safety rails have to be in place before users arrive. Two silent failures came out of v0.40.0:

  • content that no content-model field matches is dropped without a word (WORK-631);
  • a by schema table with a misspelt row falls back to the default type (WORK-632).

A maintainer finds these with a test. A user writing their first definition does not, so both land in the same milestone that lets them write one.

What lands

Seven work items in four groups:

  • Delivery (2):

    • WORK-633: a plugin declares runeDir, proved through the npm pack harness;
    • WORK-634: a project's runes.dir, read through ProjectFiles, with D8's precedence, D10's dev invalidation and the enforcement of SPEC-145 D25's ban on schema in user definitions.
  • Safety rails (2): WORK-631 reports unmatched content, and WORK-632 checks rows against matches.

  • Storytelling, beside the plugin (2):

    • WORK-635 composes lore;
    • WORK-636 composes realm and faction.

    With bond and character, that is five of the seven runes. Only plot and storyboard remain.

  • Docs (1): WORK-637 writes the user-facing authoring guide, with worked examples taken from the tested fixtures.

The acceptance test

ItemContractsSEO baseline
WORK-633none, unless plan's chosen rune moves it; reviewedsame
WORK-634, WORK-637nonenone
WORK-631nonenone
WORK-632none, unless a shipped table is fixed; reviewedsame
WORK-635, WORK-636nonenone; compared in their own fixtures

Sequencing inside the milestone

  • WORK-633 → WORK-634 → WORK-637.
  • WORK-631 should land before WORK-634 merges if it can, because it is the rail users need most. It is not a hard block.
  • WORK-632, WORK-635 and WORK-636 are independent. WORK-635/636 use a fixture plugin's runeDir if WORK-633 has landed, and a string otherwise.

So three tracks can run in parallel: delivery, the rails, and storytelling.

Deliberately not here

Swapping the plugin's runes for the composed ones. SPEC-147 D1 keeps the plugin until a separate decision. That decision also has to settle the cross-link constraint WORK-625 found: auto-linking skips nested runes, and composed prose sits inside card.

plot and storyboard. They need sequence's modifier-source form and a segmented content model (SPEC-147 implementation notes 1, 2 and 4). Promoting entity auto-linking to core (note 3) waits on the same swap decision.

The rest of SPEC-145's surface. None of these is needed by the five runes here:

  • D27 (a)/(b): variant schema for playlist;
  • D13: i18n keying;
  • D14/D24: the chrome carrier;
  • D15: multiple templates;
  • D11's editor half;
  • $slots;
  • inspect showing the expansion.

BUG-033, BUG-034, WORK-629, SPEC-144's sentinel half, SPEC-156, SPEC-159, and the v1.0.0 re-scope.

Delivering composed runes

SPEC-153shipped

Delivering composed runes

runescompositionpackaginghosteddxplugins

Summary

SPEC-145 says what a composed rune is. This says how one arrives — from a user's own repository, and from inside an installed plugin. Split out because it is packaging and loading rather than composition semantics, and because the investigation turned up a live defect and a dead mechanism that any delivery design has to reckon with.

The shape, in one line:

One file format, two read paths, one in-memory shape.

A plugin-shipped composed rune is byte-identical to a user-authored one. Only the loader differs, and it differs for a reason that is structural rather than aesthetic: SPEC-113's ProjectFiles cannot reach inside a package.

Why the formats must be identical

ADR-037 gives users the composed emit path and nothing else. That makes the composed loader the only way a rune reaches a site from outside this repo — and a code path with no first-party consumer rots. The alternative, where a plugin compiles its composed runes into Plugin.runes as JS at build time, has two costs the repo already knows the price of: a second source of truth that can drift from the .md (the same lock-step problem contracts/structures.json pays for in two committed copies), and a user-facing path that first-party code never exercises.

SPEC-152 supplies the forcing function. The plan plugin stays a plugin and its five entity runes are composable, so it is the first plugin that wants to ship composed runes — and it will ship them as the same files a user writes, or the user's path has no in-repo consumer at all.

Four delivery mechanisms already exist

Counted rather than assumed, and it matters which is live:

MechanismShapeAudienceLive users
runes.local{ name: modulePath } in config → import() of JSplugin authors pre-publishdocumented (plugin-authoring/authoring.md:325)
Plugin.fileRoots / site.fileRootsnamespace → directory of .md, package- or project-relativebothyes — partials (SPEC-063)
discoverPluginFixturesconvention directory <pkg>/fixtures/*.md, require.resolvedpluginsnone
inline fixture: stringsJS string literal in the plugin's index.tspluginsyes — all nine plugins

So the repo's actual precedent for a plugin shipping rune content is inline in JS. The file-based convention path is an unexercised hypothesis, and the declared file-root path is the one that has run in production.

The convention path is broken three ways, and silently

discoverPluginFixtures is the closest existing analogue to plugin-shipped .md runes. Each of these was measured, not inferred:

  1. No plugin has a fixtures/ directory. Zero of nine. The function has never run against real input.

  2. files would not publish one. npm pack --dry-run on plugins/plan yields exactly dist and package.json — 157 files, no other top-level entry. Its files field also declares styles, which does not exist on disk, so the field has already drifted from reality.

  3. require.resolve('<pkg>/package.json') throws for two plugins. @refrakt-md/plan and @refrakt-md/docs declare an exports map with no ./package.json entry:

    @refrakt-md/plan      FAILED ERR_PACKAGE_PATH_NOT_EXPORTED
    @refrakt-md/marketing -> /home/user/refrakt/plugins/marketing/package.json
    

    marketing resolves only because it has no exports map at all. The cause is the exports map, not a workspace link — and the function's own catch {} comment attributes it to "Plugin not resolvable via require (e.g., workspace link)", which is wrong about its own failure mode.

Re-measured across all nine, and the result is worse than "two are misconfigured". Every claim above re-verifies, and widening the probe from two plugins to nine changes what it means:

CountDetail
exports map declared2plan, docs — both fail
No exports map7all resolve
Declares ./package.json0none of the nine

So the seven that work work by accident. They resolve because they have no exports map, not because they export their manifest — nobody does. Adding an exports map is routine modernisation (it is what plan and docs already did, presumably for their ./cli-plugin and subpath entries), so the convention path is not 2/9 broken: it is 2/9 broken and 7/9 one ordinary change away. A mechanism whose working cases are all accidents is the argument for D2 rather than a caveat on it.

And files is unanimous: no plugin would publish a runes/ directory. All nine declare dist and nothing else, bar two additions — learning's i18n, which exists, and plan's styles, which does not. So the drift noted in point 2 is specific to plan and the publish gap is universal.

All three degrade to the same thing: refrakt inspect shows no fixture. Invisible, harmless, which is exactly why none was noticed.

For runes the identical degradation is an undefined tag on every page that uses it. That asymmetry is the whole argument of this spec. A delivery mechanism copied from this one would ship plugins whose runes work in the monorepo and are absent from the tarball.

ProjectFiles decides the split

SPEC-113's seam is project-root-relative keys with containment enforced — absolute paths and post-normalisation .. are rejected and treated as absent. In a hosted build the provider is memoryProjectFiles, holding only the project's own keys. A plugin's files are therefore not merely awkward to reach through it; they are structurally absent.

So the two channels cannot share a reader, and each gets the one that fits:

Project runesPlugin runes
Read throughProjectFiles.list / .readpackage-relative, at loadPlugin
Whenper buildonce, at import
Hostedworks — the keys are in the mapworks — the package is on disk
Watchableyesno, and should not be
Traversal safetythe provider's, not the caller'sn/a — a fixed declared directory

And the plugin side gets an invariant that makes an fs read safe there:

A plugin's .md runes are exactly as available as its JS is.

If import('@refrakt-md/plan') succeeded, the package is on disk and its own directory is readable. Any failure past that point is a packaging bug — a missing files entry, a blocked exports path — never an absent-file condition. Which is why D3 makes it throw.

Declaration for packages, convention for projects

The asymmetry is deliberate and the justification is concrete rather than a matter of taste:

  • A package has a publish step that drops files and an exports map that blocks resolution. Both failures were measured above. A declaration gives the validator something to check against.
  • A project has neither. Its files are the files.

And the project side already has this exact pattern: contentDir is a required, declared directory whose contents are discovered. runes.dir is that shape again, not a new concept in the config — which also means the runes config key it joins (prefer, aliases, local) stays the one place rune resolution is configured.

A correction to the runes.local reading

An earlier framing of this — in conversation, not in a spec — described runes.local as a shipped user-facing path for authoring runes with a hand-written transform, and so as a live counterexample to ADR-037. That overstates it. Its documentation is headed "Referencing Your Package → During Development (Local Files)": its purpose is a plugin author's inner loop before publishing, and a plugin author is on the code path ADR-037 assigns the declared emit path to. So its intent is consistent with ADR-037.

What remains true is narrower and still worth settling: nothing in runes.local restricts it to that use, it takes a JS module path so a rune definition cannot go through it at all, and ADR-037 does not mention it. That is a documentation and scope question (D7), not a contradiction.

Decisions

D1 — one file format, two read paths, one in-memory shape

A plugin-shipped composed rune and a user-authored one are the same file. The loader materialises both into the same Rune, so nothing downstream — engine, contracts, inspect, the editor — can tell them apart or needs to.

D2 — a plugin declares its rune directory; it is not discovered by convention

Plugin.runeDir, package-relative, resolved the way fileRoots already resolves. Not a hardcoded runes/ scan, because the declaration is what refrakt plugin validate can check and what a reader of package.json can see. The evidence for preferring the declared form over the conventional one is the three measured breakages above: every one of them is a property of the package, and a declaration is where a package states its properties.

D3 — a plugin's rune read failure throws; it never degrades to zero

Given the D5 invariant — the files are as available as the JS — a failed read means a packaging bug, so it is reported with the resolved path and the reason. The discoverPluginFixtures catch {} is not the pattern to copy, and its comment should be corrected regardless of whether it gains a caller.

D4 — the project's rune directory is declared once, its contents discovered

runes.dir, following contentDir's shape. Recommended default runes, so an existing project can add one without a config edit — but the default is the one part of this decision that is a convenience rather than a consequence, and create-refrakt should scaffold the key either way.

D5 — project runes read through ProjectFiles; plugin runes do not

Not an inconsistency. A project rune is project content: it changes between builds, it must work under memoryProjectFiles, and its path is user-supplied so containment matters. A plugin rune is none of those.

D6 — publishability is a validated gate, not a convention

refrakt plugin validate checks that the declared rune directory is covered by files and that the package is resolvable (a ./package.json export, or no exports map). Both failures are invisible in the monorepo, so the acceptance gate is npm pack → install → load, not a unit test.

D7 — runes.local keeps its documented scope, stated explicitly

Its documented purpose is unpublished plugin development. That is recorded in ADR-037's own terms so the relationship stops being implicit, and it rejects a composed-rune path rather than half-accepting one: it takes a JS module and a rune definition is not one.

D8 — precedence is core < plugin < project, and core names are not shadowable

A project rune may not take a core rune's name. {% card %} meaning something local breaks every snippet, every doc page and every composed rune built on it — the same portability premise ADR-028 rests on, one layer out. A project rune may shadow a plugin rune, because runes.prefer already exists for exactly that and makes the choice explicit.

D9 — one file per composed rune, named <rune>.md, and the filename is authoritative

There is no second file, and the justification got simpler than it was: an earlier revision said ADR-035's <rune>.skin.md "has no counterpart here", leaving the two-file split a theme concern. ADR-035 is now rejected, so there is no skin format at all and no pair to be half of.

So the file is playlist.md, not playlist.rune.md. A suffix earns its keep when a file must be identified out of context, and these never are: a plugin declares its rune directory (D2) and a project's is a declared directory whose contents are discovered (D4), so the directory always says what they are. That matches every other convention in the repo — site/content/**, fixtures/*.md, plan/specs/*.md are all directory-scoped, _layout.md is the single prefix special case, and nothing uses a double extension.

The filename carries the rune's name, and frontmatter does not repeat it. This repo has already run the other experiment: plan entities are {ID}-{slug}.md and carry id="…" in frontmatter, two sources of truth for one fact, and they drifted badly enough to need refrakt plan migrate filenames --apply --git. So rune: comes out of the frontmatter — resolution needs the filename anyway ({% playlist %} → <runeDir>/playlist.md is the cheapest possible lookup), and a rename becomes just a rename. Frontmatter keeps what a filename cannot carry: tag, aliases, attributes, content, schema, metaFields, blocks and registers.

The fixture is separated by directory, not by suffix. SPEC-102 fixtures are also .md, so fixtures/playlist.md sits beside runes/playlist.md — which is what plugins already do — rather than introducing playlist.fixture.md.

D10 — a rune edit invalidates every page that uses it

setupContentHmr(server, contentDir, examplesDir, invalidate) (packages/transform/src/content-hmr.ts:41) watches two directories, and neither the project's rune directory nor any fileRoot is among them. Adding the watch is small; the part that is not is the granularity — editing a rune changes every page that uses it, which is a different invalidation shape from a content edit. Noted here because it is the kind of thing that gets discovered as a stale-page bug rather than designed.

D11 — the composed variant reaches PluginRune

validatePlugin currently requires entry.transform on every rune and loadPlugin passes it straight to defineRune. SPEC-145 D5's mutual exclusion has to reach that type, or a plugin's composed rune fails validation for lacking the thing it is defined by not having.

What this does not do

A plugin with no JavaScript is newly possible — a pack of .md runes needs no transform, no config and no CSS — and this spec does not introduce it. loadPlugin requires import(), findPluginExport and validatePlugin, so a JS-free pack means relaxing three things at once and inventing a second loader entry point. D2's shape does not preclude it later; it is simply a different change, and the hosted product is the consumer that would justify it.

Status after v0.40.0

Implementation notes 1 and 2 are done:

  • WORK-626: every plugin's manifest resolves, and files matches disk.
  • WORK-627: the npm pack → install → load harness.

Notes 3–5 are open: plugin rune directories, project rune directories, and the discoverPluginFixtures caller. In v0.40.0, composed definitions reached the pipeline as strings. This spec stays draft. plan validate's suggestion to advance it is expected until notes 3–5 have work behind them.

Implementation notes, deliberately not yet work items

Kept here rather than filed, so the adopting milestone decides its own breakdown.

  1. Fix the packaging prerequisites first — a ./package.json export (or no exports map) and files coverage, on every plugin. Everything else in this spec rests on the package being readable, and all three failures are invisible in the monorepo, so this is the step that must not be assumed.
  2. Build the npm pack → install → load harness. It is the only acceptance gate that catches the measured breakages, and the one piece here with no existing equivalent.
  3. Plugin side before project side. Plugin.runeDir + D11's PluginRune variant, with plan as the consumer, proves the file format from inside the repo where debugging is cheap and the fixture corpus already exists.
  4. Project side after. runes.dir, read through ProjectFiles, with D8's precedence check and D10's watch. The hosted case is the reason this exists, so it is tested against memoryProjectFiles and not only against disk.
  5. Correct the discoverPluginFixtures comment regardless of whether it ever gains a caller — it currently misattributes its own failure mode.

Non-goals

  • Changing what a composed rune's file contains (SPEC-145)
  • A JS-free plugin or rune pack, per the section above
  • Retiring runes.local (D7 scopes it instead)
  • Relaxing ProjectFiles' containment rules, or routing package reads through it (D5)
  • Fixing discoverPluginFixtures' three breakages as a prerequisite — the comment correction is in scope, a caller for it is not
  • Reworking fileRoots, which this borrows the resolution technique from and otherwise leaves alone

Acceptance Criteria

  • A composed rune file loads identically from a project directory and from a plugin's declared rune directory, asserted by loading the same file both ways and comparing the resulting Rune
  • @refrakt-md/plan ships at least one composed rune in its published tarball, verified by npm pack → install → load in a fixture project, not by a monorepo test
  • A plugin whose declared rune directory is missing from files, or whose package is unresolvable, fails refrakt plugin validate with the resolved path and the cause (D6)
  • A plugin rune read failure throws rather than yielding an empty set, asserted by a fixture plugin with a blocked ./package.json export (D3)
  • discoverPluginFixtures' comment no longer attributes the failure to workspace links
  • Project runes resolve under memoryProjectFiles with no filesystem, and a rune path escaping the project root is refused by the provider rather than by the loader (D5)
  • A project rune taking a core rune's name is rejected at load with both names reported; taking a plugin rune's name requires a runes.prefer entry (D8)
  • Editing a file in the project's rune directory in dev invalidates every page using that rune, asserted on a two-page fixture where only one page uses it (D10)
  • A plugin rune entry carrying a template and no transform passes validatePlugin, and one carrying both is rejected naming the rune (D11)
  • A composed rune is named <rune>.md, its name is read from the filename, and a frontmatter key restating it is rejected rather than silently preferred (D9)
  • create-refrakt scaffolds the runes.dir key, and the authoring guide states the project-versus-package asymmetry with the reason rather than as a convention

References

  • SPEC-145 — composed runes; what is being delivered, and D2 / D5 / D12 which this depends on
  • ADR-037 — users author composed runes only; why the composed loader is the only external path, and what D7 scopes against
  • SPEC-152 — the plan audit; the first plugin that stays and still wants to ship composed runes
  • SPEC-113 — ProjectFiles; the seam that decides D5
  • SPEC-063 — file roots and partial resolution; the package-relative resolution technique D2 borrows
  • SPEC-102 — the standardised fixture format, whose file-discovery half is the mechanism measured here
  • ADR-035 — the skin format, rejected; D9 is where the one-file naming decision lives instead
  • SPEC-102 — the standardised fixture format D9 separates by directory rather than by suffix
  • ADR-028 — the portability premise D8 extends from themes to rune names

Composed runes

SPEC-145accepted

Composed runes

runescompositionmarkdocseohostedarchitecture

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" }