v0.39.0

Released October 7, 2026 · All releases

Declarations, part two. More of what runes did in code is now declared as data: a rune can declare what it registers in the cross-page registry and which output slots it fills, and the storytelling, design and plan runes have moved over with unchanged output. {% figure %} now keeps everything you put in it and captions it, instead of silently dropping anything that is not an image.

warning

Action needed: a standalone {% snippet %} no longer renders inside <figure class="rf-snippet">. It is now the <pre data-source> a fenced code block produces, so CSS or tooling that selects .rf-snippet or [data-source-path] must move to pre[data-source]. And parentUrl is now spelled exactly like the parent page's url: a query on parentUrl:/docs/ must use parentUrl:/docs.

Background: · · ·

Changes

  • A rune can declare what it registers in the cross-page registry (WORK-611, WORK-612, WORK-613)
  • createContentModelSchema takes a registers block: an entity (with type, idFrom, scope, data and aliases) or an edge (with from, to, kind, bidirectional and data). The block is plain data. It is checked when the module loads, and a function, an unknown key or a class instance is an error. One core participant does the registering in the same Phase 2 and Phase 3 slots a plugin's register and aggregate hooks use. It runs inside the declaring plugin's hook set, beside any hooks the plugin still writes. A plugin that writes no aggregate of its own gets the participant's name index in its aggregated slot, as { entityByName }. Declared edges are added to the relationship graph, so getRelated and the relationships rune can find them.
  • A source that the rune never provides is reported by refrakt validate as registers-source-unresolved, with the file and line of the rune instance. That id is now on by default. refrakt inspect shows the block, and its JSON output has a new registers field. refrakt reference documents it.
  • Storytelling and design now register this way, and their register and aggregate hooks have been removed. Their registries are unchanged: the same entries in the same order, with the same data. Snapshot tests captured before the migration enforce this. Two outputs did change:
  • bond edges are now in the relationship graph, with the bond's type as the edge kind. An endpoint written as an alias resolves to the character's id. Storytelling's aggregated slot no longer has relationships or orphanedBonds. Nothing in this repository read them, and the unknown-endpoint warnings are still reported.
  • Design's aggregated slot is now { entityByName } instead of { contexts }. The editor's sandbox preview and its context= autocomplete read the new shape, and /api/aggregated now sends Maps as plain objects.
  • Structure contracts and the SEO baseline are unchanged.
  • A rune can declare its output slots instead of writing a transform (WORK-614, WORK-616)
  • createContentModelSchema takes an emits declaration as the alternative to transform: the renderable's identity (rune, tag, property), its property metas in fieldMetas' data form, and its named content slots, each a value or a region read from one resolved field or attribute. The schema builds the transform from it once, at construction, and calls it where it would call a hand-written one, so the output is the same either way. The declaration is plain data: functions, nesting, ordering and containers are rejected when the schema is built, as are a slot that would put one data-name on two nodes and a rune that declares both transform and emits, or neither. The rune's sections join table is derived from its slots.
  • work, bug and decision in @refrakt-md/plan now use it. Their rendered output is unchanged.
  • refrakt inspect and refrakt reference list a declared rune's slots, with every default spelled out (slots and emits in JSON output).
  • {% figure %} keeps everything in its body and captions it (BUG-028)
  • A figure used to emit only its images and its caption. Any other child, such as a fenced code block, a table, a nested rune, or a second paragraph, was transformed and then dropped without a warning, so a figure wrapping a code block rendered a caption under nothing. Figure is now a general captioned container. Every child is kept in the order it was written. Media (an image, a video, a placeholder: or icon: image) is still the figure's media slot and is unwrapped from its paragraph as before. The caption still comes from the caption attribute or the first paragraph without an image, and still renders last. A figure holding only images renders exactly as it did.
  • The structured data follows the content. A figure whose body is only media is still an ImageObject with contentUrl and caption. A figure holding anything else now publishes no schema.org type and no properties, where it used to claim ImageObject regardless, even with no image in it. Calling a captioned code block an image was a false statement in the page's JSON-LD and RDFa.
  • The rule lives in figure's schema table. Tables gain byField, which selects a row from a value the rune's transform records in its field bag, for a type that depends on what the content is rather than on an attribute. refrakt inspect, refrakt contracts and refrakt reference show it.
  • Lumina's and the skeleton's figure image and caption rules now apply only to the figure's own children. An image inside a nested rune in a figure body keeps its own styling.
  • A standalone snippet renders as a bare <pre data-source> (WORK-615)
  • Breaking output change. {% snippet %} used to wrap a standalone code block in <figure class="rf-snippet" data-rune="snippet" data-source-path="…">. The wrapper is gone: a standalone snippet now renders as the <pre> a fenced code block produces, with nothing around it. The figure carried nothing the <pre> did not already have. data-source (the project-relative path) and data-lines (the resolved range) are on the <pre> itself.
  • CSS or tooling that selects .rf-snippet or [data-source-path] must move to pre[data-source]. A snippet inside {% codegroup %} or {% diff %} is unchanged, because it was never wrapped. For chrome around a single snippet, compose it with {% codegroup title="…" %}.
  • Lumina drops its .rf-snippet rules. They only reset the margins the figure introduced. The file-ref preview drawer used the same figure for its body, so the drawer now holds the same bare <pre data-source>. Structure contracts and the SEO baseline are unchanged.
  • Runes resolve before the transform in tree order, and a rune can declare preprocess (WORK-618, BUG-027)
  • createContentModelSchema accepts a preprocess(node, page, ctx) hook beside transform. It receives the rune's own tag node and returns a replacement, an array of nodes to splice in its place, or nothing. ctx is the page's PreprocessContext plus ancestors, the enclosing nodes from the document down. include, snippet and data now declare theirs this way. The core pipeline hook runs one walk over the page and calls each rune's hook where it meets the tag. Plugin runes in the site's tag table take part the same way. preprocessTree, RunePreprocess and RunePreprocessContext are exported. PluginPipelineHooks.preprocess is unchanged.
  • The walk continues into whatever a hook returns. The order between preprocessing runes therefore follows where they sit in the page, and is no longer a fixed sequence of include, snippet, data. This fixes a {% snippet path=$row.path %} inside a {% data %} row template. The snippet used to resolve before any row was bound, so every row rendered the same "path attribute is required" error fence. It now reads the file each row names. An {% include file=$row.file %} in a row template works the same way. Pages that do not combine these runes render as before. Structure contracts and the SEO baseline are unchanged.
  • breadcrumb auto renders the full ancestor chain at every depth (BUG-018)
  • A page's parentUrl used to be spelled with a trailing slash (/docs/), while the router registers pages without one (/docs). Every lookup keyed on it missed below depth 1, so four things were broken on every site with nested content:
  • {% breadcrumb auto=true %} on /docs/api/ref rendered and published Ref alone instead of Home > Docs > API > Ref
  • the aggregated pageTree was flat, with every page a direct child of the root
  • {% nav auto=true %} on a section index such as /docs found no child pages
  • {% pagination auto=true %} on a nested section index was not suppressed
  • parentUrl is now spelled exactly as the parent page's url is, so all four resolve. When no page sits at the parent path, parentUrl is that path without the trailing slash. A query that matched the old value (parentUrl:/docs/) must use parentUrl:/docs instead. The breadcrumb walk now steps over a directory that has no index page instead of stopping there. An ancestor that is on the breadcrumb path but has no page entry now produces a pipeline warning. Before, it was dropped without one.
  • Pages using breadcrumb auto below depth 1 change their rendered HTML and their BreadcrumbList JSON-LD. Structure contracts and the SEO baseline are unchanged, because the baseline harness transforms fixtures one page at a time and does not run the cross-page pipeline.
  • Grouping by a multi-value field puts an entity in one group per value (BUG-025)
  • collection, aggregate and backlog all accept group="<field>". For a field holding several comma-separated values, such as tags, source or pr, the group key used to be the whole string, so an entity tagged runes, data, csv landed in a group called runes, data, csv. Against this repository's own plan, group="tags" gave 719 groups for 803 entities, and counting work items per spec read SPEC-008 as 16 when it has 19.
  • Grouping now splits the value the same way filter already did. The value is split on commas, each member is trimmed, empty members are ignored, and an array is split by element. The entity joins every group whose value it carries. A group's size now means "entities carrying this value", so per-group counts can add up to more than the entity count. aggregate's total still counts each entity once. Grouping by a single-valued field such as status or priority is unchanged, and an entity with no value still groups under (none).
  • sort on a multi-value field no longer orders by the joined string either. An entity sorts by its smallest value ascending and its largest descending. With a declared order, it sorts by its best-ranked value ascending and its worst-ranked value descending. Sorting by a single-valued field is unchanged.
  • @refrakt-md/runes also exports fieldMembers and groupKeys, the split used for grouping, next to the display helper fieldValue.
  • A rune nested by an author is no longer published as one of its parent's children (WORK-609)
  • A schema table's children key matches by rune name as well as by data-name, and several keys are also real rune names: track, step, tier, breadcrumb-item. The match used to reach anywhere below the parent, so a rune of that name the author had nested inside some other rune was claimed by the parent and published in its JSON-LD with readable values:
  • a {% track %} inside a {% hint %} in a playlist became one of the album's tracks
  • a {% steps %} block inside a recipe tip became part of recipeInstructions
  • a {% tier %} nested in another tier's body became one of the pricing table's offers
  • The match now stops at another rune's boundary. The nested rune keeps its own structured data and is published as its own entity. A parent's own children resolve exactly as before, and pages that do not nest a rune like this produce the same output. The SEO baseline gains three fixtures for these cases. Structure contracts are unchanged.
  • This adds the ownership marker (data-owner) from SPEC-146. A node placed inside another rune on a rune's behalf will carry it, so the parent can still find that node past the boundary. Nothing sets the marker yet, and it is removed before rendering.
  • A schema table can resolve a named node placed inside another rune on its behalf (WORK-610)
  • findAllByName, which resolves a schema row's node-sourced values (properties from a node, text, and the node half of entities), used to stop at any nested rune. It now keeps descending past that boundary but admits only nodes marked data-owner for the resolving rune, the same rule findChildren follows. A nested rune's own names stay out of reach, so a character is still not named after its sections' headings.
  • Nothing sets the marker yet, so published output is unchanged. The SEO baseline and structure contracts show no diff.

v0.39.0 — Declarations, part two

v0.39.0complete

v0.39.0 — Declarations, part two

v0.38.0 deleted what was provably inert and made two declarations reach the reader and the runtime. This milestone continues on the same line, one level up: the remaining places where a rune's behaviour is written as code but is really data, plus the one place where the two schema resolvers' disagreement already publishes wrong structured data.

These are also the self-contained foundations of the composed-runes work (SPEC-145, SPEC-153, SPEC-156). Each one is valuable on its own and does not depend on composition landing. Composition itself is deliberately not in this milestone.

Why

A resolver over-matches today, and it reaches the JSON-LD graph. findChildren matches a schema children key against data-rune anywhere in the subtree, so an author's own {% track %} nested inside a {% playlist %}'s unrelated content is published as one of the playlist's tracks (SPEC-146 Problem 1). This is the only live correctness defect among the specs, and it ships first.

Registration is data tables written as switch statements. Storytelling's register/aggregate hooks are a generic walk plus two switches whose every arm is a field list (SPEC-144). Three declaration shapes replace them, and once they exist a rune can enter the cross-page graph without a plugin hook.

Labelling restates the content model. 58 of 93 refs keys exactly match a declared content-model field name (SPEC-143). For entity-shaped runes the transform is four rules and a list, and the declaration already holds most of them.

Preprocess order is a guess maintained by hand, and it guesses wrong once. Three whole-AST passes in a fixed order cannot express data → snippet (BUG-027). Resolving them in tree order derives the ordering from node position and fixes that bug as a by-product (SPEC-141). It was held out of v0.38.0 only because v0.37.0 was rewriting the same file, and that work is finished.

What lands

Ten work items in four groups, plus four bug riders:

  • Name resolution (2) — WORK-609 fixes the findChildren over-match and introduces the ownership marker; WORK-610 uses the marker so findAllByName can see owner-marked content across a boundary, and must be inert today.
  • Declarative registration (3) — WORK-611 adds registers.entity / registers.edge and the core hook; WORK-612 and WORK-613 migrate storytelling and design.
  • Declarative slot labelling (3) — WORK-614 adds the slot declaration and the generated transform; WORK-616 (work, bug, decision) proves it on the resolved-entry side of the emitTag split. WORK-617 (character, realm, faction) is cancelled: on contact all three failed SPEC-143's D4 family test (nested named nodes, pick-by-node-kind, filtering rendered output, a merge across two slots), so they are deferred to the composed-runes work (SPEC-145 / SPEC-147), which replaces them rather than declaring their current transforms. The emitTag side is covered by the mechanism's own tests.
  • Tree-order preprocess (2) — WORK-615 removes snippet's figure wrapper (breaking output change, changeset); WORK-618 replaces the three passes with one walk.
  • Riders (4) — all major:
    • BUG-027 — closed by WORK-618.
    • BUG-018 — breadcrumb auto loses every ancestor below depth 1. Check the suspected pageTree blast radius first.
    • BUG-025 — grouping by a multi-value field keys on the joined string.
    • BUG-028 — figure drops non-image children. Decided: figure becomes a general captioned container (see the bug's Decision section). That also gives authors the chrome WORK-615 takes away from standalone snippets.

The acceptance test does most of the work, again

As in v0.38.0, contracts/structures.json and contracts/seo-baseline/baseline.json are the gate. The required diff differs per group, and confusing those expectations is the main risk in this milestone:

ItemContractsSEO baseline
WORK-609nonea reviewed diff — the fix is supposed to move it (SPEC-146 D3)
WORK-610, WORK-614/616, WORK-618 (WORK-617 cancelled)nonenone
WORK-611/612/613nonenone — plus a byte-identical registry snapshot, order included (SPEC-144 D4)
WORK-615the wrapper removal onlynone
BUG-018, BUG-028expected to move; reviewedexpected to move; reviewed

A refactor row that moves either file has changed behaviour, whatever the intent.

Sequencing inside the milestone

  • WORK-609 → WORK-610: the marker is introduced by the first.
  • WORK-611 → WORK-612, WORK-613.
  • WORK-614 → WORK-616. (WORK-617 was cancelled — see above.)
  • WORK-615 → WORK-618 → BUG-027 (SPEC-141 D5: the wrapper goes first, separately).
  • WORK-612 and WORK-617 both rewrote the storytelling plugin, so they were to land one after the other. With WORK-617 cancelled, WORK-612 has the plugin to itself.

The four groups are independent of one another and can run in parallel.

Deliberately not here

Declarable sentinel resolution — SPEC-144's revised D2 and its two related criteria. Registration is the self-contained half. The sentinel half migrates five core postProcess instances, buildAutoBreadcrumb among them, and BUG-018 changes that function in this milestone. It stays in the spec for a follow-up.

SPEC-142 — inferring the transform signature. It would type the slot declaration, so it reads better after SPEC-143 settles that declaration's shape.

SPEC-145 and the composition programme (SPEC-147–159). This milestone builds their prerequisites. The programme is the next milestone's subject.

unwrap, and expected-slot diagnostics. SPEC-143 names both as candidates and neither as criteria. The mechanism proves itself on labelling alone first.

Declarative entity and edge registration

SPEC-144accepted

Declarative entity and edge registration

runespipelineregistrydeclarativepluginshosted

Summary

A rune declares that it registers a named entity, or an edge between two, instead of a plugin writing a register/aggregate hook to do it. The consuming half — the registry, the relationship graph, the runes that query them — already exists and is already generic. Only declaration is imperative, and the imperative code that does it turns out to be data tables written as switch statements.

This is the smallest independently valuable piece of the user-defined-rune programme. It needs neither SPEC-143 nor SPEC-145, ships value to the plugins in this repo on its own, and removes the single largest obstacle to a rune being definable without code.

Background — the power is in the graph, not the transform

What makes the storytelling plugin more than a set of pretty boxes is that a bond resolves between two characters defined on different pages, and that a character's aliases are addressable. That is cross-page behaviour, and it is the part of a domain plugin an author would most want and least be able to write.

The query side of it is already core and already domain-agnostic:

  • EntityRegistry with register, getAll, getByUrl, getById, and getRelated over a relationship graph of ResolvedEdges (packages/types/src/pipeline.ts:40-145, SPEC-072)
  • collection, relationships and aggregate runes that query it generically, with resolvers in packages/runes/src/{collection,relationships,aggregate}-resolve.ts

So a user-defined rune can already be queried. It cannot get into the registry without a plugin.

Problem — the registration code is a data table in disguise

plugins/storytelling/src/pipeline.ts is 319 lines. Its register hook (:140-181) is a loop with no domain logic in it: walk the page's tags, check whether data-rune is in a set of entity types, extract a name, call registry.register({ type, id: name, sourceUrl, data }).

The two helpers it calls are switch statements over the rune name whose every arm is a field list:

function extractEntityName(tag, runeType): string {
  switch (runeType) {
    case 'character': case 'realm': case 'faction': return readRefText(tag, 'name');
    case 'lore':      case 'plot':                  return readRefText(tag, 'title');
    default: return '';
  }
}

function extractEntityData(tag, runeType) {
  switch (runeType) {
    case 'character':
      data.role = readField(tag, 'role');
      data.status = readField(tag, 'status');
      data.aliases = readField(tag, 'aliases');
      data.tags = readField(tag, 'tags');
      break;
    // …five more arms of the same shape

aggregate (:183-220) then builds a name→entity map and expands character aliases from a comma-separated field. Also pattern, also no logic.

The one genuinely different case is bond, and it is different because it is a relationship rather than an entity: its id is ${from}→${to} and it reads two ref fields rather than one name.

Three declarations replace all of it. Nothing in that file requires the ability to run code; it requires the ability to say which field holds the id, which fields to carry, and which two fields form an edge.

Mechanism

A rune's declaration gains an optional registers block. It is inert data — ADR-036's closed-vocabulary rule applies, so there is no expression form and no predicate.

# character
registers:
  entity:
    type: character          # defaults to the rune name
    idFrom: name             # a ref name or a field-bag key
    scope: site              # 'site' (default) | 'page'
    data: [role, status, aliases, tags]
    aliases: { from: aliases, separator: "," }

# bond
registers:
  edge:
    from: from               # ref or field holding the source entity's id
    to: to
    kind: bond               # a literal, or `{ field: kind }` to read one

Core gains one pipeline hook that runs for every rune carrying a registers block, in the existing Phase 2 / Phase 3 slots described in CLAUDE.md. Plugins keep their own hooks for everything else; this adds a generic participant, it does not replace the hook mechanism.

Where the values come from. idFrom and data name either a refs entry (read as its text content, as readRefText does today) or a data-rune-fields bag key (as readField does). The two namespaces are disjoint, so a single name resolves unambiguously — the implementation tries the ref first and falls back to the bag, exactly as the hand-written helpers already do in aggregate. Whether that fallback stays implicit or the declaration must say which channel is D3.

Reach — honest, and narrower than it looks

Three plugins have pipeline hooks. They do not have the same shape, and this spec should not pretend otherwise:

Pluginpipeline.tsShapeReachable?
storytelling319Generic walk + two field-list switches + alias expansion; one relationship caseYes — the motivating case
design92Same shape, smallerYes
plan1,120configure hook, module-level state, an unconditional filesystem scan outside the content tree, dependency edges parsed from H2 prose sections, Blocks edges belonging to a file that does not own the entityNo

So the claim is: two of three plugins can shed their registration code, and the third demonstrates why the hook mechanism has to stay. The plan plugin is the counter-example that keeps this spec from overreaching — its edges come from prose, its scan reaches outside any site, and no field-list declaration expresses either.

Re-verified, and the table holds exactly: plugins/storytelling/src/pipeline.ts is 319 lines, design's is 92, plan's is 1,120. D2's five first-party sentinel instances also land where cited — packages/runes/src/config.ts:214, :238, :244, :250 and :966, each on a comment describing that exact emit-then-resolve pattern.

And this reach is measured, which the greenfield consumers named elsewhere are not. SPEC-154 calls prerequisite this spec's first greenfield consumer, and prerequisite does not exist — WORK-011 is pending and plugins/learning/src/tags/ holds howto.ts and recipe.ts and nothing else. That is a fair thing to say about a planned rune and a wrong thing to add to the table above. Recorded because ADR-039 rule 2 made precisely that addition and had to retract it: planned consumers justify a mechanism's shape, never its reach.

What this unlocks that is not in this repo

A user-defined rune with a registers block participates in the cross-page graph with no code. That means the collection, relationships and aggregate runes work against user domains on day one — which is the difference between a user-defined rune being a styled box and being a content type.

It is also the piece that makes SPEC-145's composed runes worth having: a composed rune's tree is built from primitives, so its identity has to come from somewhere, and a registers block on the outer declaration is where cross-page identity lives.

Decisions

D1 — the vocabulary is closed, per ADR-036

entity and edge, with named fields. No expression form for idFrom, no predicate for "which tags count", no computed kind beyond reading a named field. A domain that needs more writes a plugin.

D2 — it declares registration, never resolution

The declaration says what enters the registry. What comes out is the existing query runes' business. In particular this spec does not add a way to declare a postProcess sentinel resolution — that is where the remaining storytelling pipeline code lives, and it stays imperative.

Revised: the exclusion splits on the scope of the write, not on the hook

Two audits pushed back on this, and they turn out to be describing different things. SPEC-147 found that excluding postProcess loses a real capability, and SPEC-154 found that glossary cannot exist without one. Treating those as the same objection is what made D2 look either too strict or too loose depending on which you read last. They separate cleanly:

Bounded — a rune resolves a placeholder it emitted itself, from aggregated data. This is a pattern with five first-party instances, all the same shape, all in packages/runes/src/config.ts: expand (:214), collection (:238), file-ref (:244), aggregate (:250) and buildAutoBreadcrumb (:966). Storytelling's is a sixth. The declaration is a lookup — which registry, keyed by what, replacing which sentinel — not arbitrary code, and it is squarely within what this spec's vocabulary can express.

Unbounded — a rune rewrites content it did not emit. glossary's own note is the statement of it: "the pipeline collects all glossary terms and rewrites matching text nodes across other pages into links." No declaration expresses that without becoming a language, which ADR-036 forbids, and the failure mode is a page changed by a rune that does not appear on it.

So: the bounded case becomes declarable; the unbounded case is the plugin escape hatch, permanently. That satisfies both witnesses rather than splitting the difference — storytelling's capability is declared rather than lost, and glossary stays a plugin rune, which is what SPEC-154 D2 concluded independently on its own evidence.

The line is checkable, which is what makes it a rule rather than a judgement: a bounded resolver only ever writes to nodes carrying its own rune's sentinel.

D3 — one open question is deliberately deferred: channel explicitness

Whether idFrom: name must say ref:name / field:name, or whether the implicit ref-then-bag fallback is good enough. The fallback matches today's behaviour and reads better; explicitness catches a typo that would otherwise register an entity with an empty id. Decide with a migration in hand, not before.

D4 — no rune migrates until the registry output is proved identical

Same gate as SPEC-143 D7. A migrated plugin must produce a byte-identical registry — same types, ids, scopes, sourceUrls and data bags, in the same registration order — because registration order decides last-write-wins on a site-scoped collision (packages/types/src/pipeline.ts:46-56).

D5 — plan is out of scope and stays out

Not a phasing decision. Its registration reads prose sections and scans the filesystem; forcing it into a declaration would either expand the vocabulary until it is a language (ADR-036) or produce a declaration that lies about what runs.

Non-goals

  • Replacing PluginPipelineHooks — this adds a generic participant beside them
  • Declaring unbounded postProcess rewriting — a rune changing content it did not emit (D2, revised). The bounded case is in scope, phased below
  • Declaring the configure hook, or anything that reads the filesystem
  • Changing EntityRegistry, the relationship graph, or any query rune
  • Making the plan plugin declarative (D5)
  • Cross-page validation of declared entities — a dangling edge target is the registry's existing concern, unchanged here

Phasing

Accepted for v0.39.0 as registration only: registers.entity / registers.edge, the core hook, and the storytelling and design migrations (WORK-611, WORK-612, WORK-613). The last two criteria below, which make bounded sentinel resolution declarable and migrate the five first-party instances, are a follow-up. One of those instances is buildAutoBreadcrumb, which BUG-018 changes in the same milestone. Migrating a function while its output is being corrected would leave D4's identical-output gate with nothing stable to compare against.

Status after v0.39.0. Registration shipped in v0.39.0 (refrakt-md/refrakt#670), and BUG-018 is fixed, so buildAutoBreadcrumb is stable again. The spec stays accepted, not implemented, because the sentinel-resolution half is still outstanding. plan status suggests the flip because every linked work item is done, but those items cover only the registration half.

Acceptance Criteria

  • A rune can declare registers.entity with type, idFrom, scope, data and aliases
  • A rune can declare registers.edge with from, to and kind
  • The declaration contains no function values and round-trips through JSON.parse(JSON.stringify(…))
  • One core hook performs the registration for every rune carrying the block, in the existing Phase 2 / Phase 3 slots
  • storytelling's register and aggregate hooks are deleted, not merely unused, and its postProcess is untouched
  • design's register and aggregate hooks are deleted the same way
  • The registry produced for the site fixtures is identical before and after each migration — types, ids, scopes, sourceUrls, data bags, and registration order (D4)
  • bond edges resolve through getRelated identically to today
  • character alias lookup resolves identically, including the "first registration wins" behaviour for a duplicate alias
  • A declaration naming a field that no emitted node, field-bag entry or attribute provides is reported at validate time, with file and line — the same treatment a schema-table source already gets
  • refrakt inspect shows a rune's registration declaration
  • The generated reference documents registers for every rune that carries one
  • The plan plugin's pipeline is unchanged, and the spec's reach table is reflected in the authoring guide
  • A bounded sentinel resolution is declarable, and the five first-party instances (expand, collection, file-ref, aggregate, buildAutoBreadcrumb) each produce identical output through the declaration (D2)
  • A declared resolver that writes to a node carrying another rune's sentinel is rejected, so the bounded/unbounded line is enforced rather than documented (D2)

References

  • SPEC-072 — the relationship graph and the query runes this declares into
  • SPEC-143 — declarative slot labelling; the same closure-over-data approach applied to the transform
  • SPEC-145 — composed runes; needs this for a composed rune's cross-page identity
  • ADR-036 — why the vocabulary is closed rather than an expression language
  • SPEC-064 — plan content registered from outside a site's content tree; part of why plan is out of scope
  • SPEC-147 — the storytelling replacement; the consumer that motivated this spec, and which finds D2's postProcess exclusion loses a capability
  • SPEC-152 — the plan audit; why D5's exclusion leaves the plugin's runes composable anyway
  • SPEC-154 — the learning audit; prerequisite is this spec's first greenfield consumer, and the planned glossary is the unbounded case D2 now names
  • SPEC-158 — the identity guard's granularity; the other axis the refinement pass separated from this one
  • ADR-039 — where counting a planned rune as measured evidence was caught and retracted
  • WORK-011 — prerequisite; pending, and the greenfield consumer the reach table does not count

Declarative slot labelling

SPEC-143accepted

Declarative slot labelling

SPEC-080 made metadata assembly declarative. SPEC-081 made structural assembly declarative. Both left the same remaining step in imperative TypeScript: turning a resolved content field into a node carrying data-name.

Give that step the declarative footing the other two already have, and the entity-shaped rune has no transform at all.

The names are already written down. A content model declares { name: 'title', match: 'heading' }; the transform then restates it as refs: { title: … }. Across the tag files, 58 of 93 refs keys exactly match a declared content-model field name — 62% pure restatement.

runestransformdeclarativearchitecturedx

Background — the arc this completes

The two-layer split is settled and this spec does not reopen it. SPEC-081 separated the transform's jobs:

JobWhere it lives
Content interpretation — which child is the headline, splitting on ---, collecting list itemsthe content model, declaratively (SPEC-003)
Structural assembly — the preamble header, the content / media split, nesting the def-listlayout, declaratively (SPEC-081)
Metadata rendering — chips, def-lists, sentiment, labelsmetaFields + blocks, declaratively (SPEC-080)
Labelling — resolved field → a node named data-name="x"transform, imperatively

SPEC-081 assigned labelling to the transform under its guiding rule: "labelling is semantics (transform); nesting / grouping is presentation (declarative)." That rule drew the right boundary — labelling is semantics. The claim here is narrower: semantics does not have to mean TypeScript, because the content model is already the semantic declaration and it already carries the names.

Mechanism — one substitution at one call site

options.transform is invoked in exactly one place, inside the Schema's own transform (packages/runes/src/lib/index.ts:865):

Schema.transform(node, config):
  attrs         = resolved attributes
  resolvedModel = options.contentModel      (or the thunk, called with attrs)
  { content }   = resolveContentModel(node.children, resolvedModel, attrs)
  result        = options.transform(content, attrs, config, node)   ← the only call
  result        = applySchemaTable(result, options.schema)          ← SPEC-130
  return result

The change is that line: when options.transform is absent, call a transform generated from the declaration instead.

A closure factory, not code generation and not an interpreter. Three readings of "compile" are possible here and only one is meant:

ReadingWhat it would beVerdict
Emit source texta build step writing .ts filesno — adds a stage, and one a hosted user could not run
new Function / evalcode built from the declaration at runtimeno — arbitrary code execution from input
Build a closure over the declarationmakeSlotTransform(options) returns a functionthis
const transform = options.transform ?? makeSlotTransform(options);

When: at createContentModelSchema(...) call time — module load, once per process when the plugin is imported, at dev-server or build startup. Not per page, not per rune instance.

The declaration stays inert data; the only code that executes is refrakt's own generator, parameterised by it. The contrast that matters is with an interpreter in the other sense — a second walk of the tree inside the engine, consulting the slot table at render time. That would mean two code paths to keep in agreement, declarative runes being a different kind of thing downstream, and "same output" as a property to be tested rather than had.

Instead: one call site, one function type, and nothing downstream can distinguish the two — resolution, applySchemaTable, serialization, BEM, layout, blocks, contracts, inspect and the pre-engine pipeline reads are untouched.

That is what makes D7's byte-identical gate provable rather than aspirational. The generated function is interchangeable with the hand-written one by construction, so a diff means the generator is wrong — never that the output legitimately moved.

Two properties follow:

  • No new inputs. The generated function receives the same content, attrs, config, node. It needs config for the reason every transform does: to run Markdoc.transform over resolved AST nodes.
  • SPEC-130's table is unaffected. applySchemaTable runs after the transform and is already independent of how the tree was built, so a declaratively-labelled rune keeps its schema.org row unchanged.

Problem

plugins/plan/src/tags/work.ts is the clean case. After SPEC-081 it builds no structure — layout does that. What remains is:

  • twelve attributes → property metas (already being collapsed by SPEC-140's utility)
  • resolved.title → a header named title
  • resolved.description → a div named blurb, only when non-empty
  • resolved.sections → a div named body
  • assemble the children array in order

Four rules and a list. Every entity rune restates the same four with different nouns, which is why the shape has already been factored twice independently — buildSections in plan and buildStoryContent in storytelling do the same job. They differ only in whether sections arrive as resolved data or as already- rendered child runes, which is the emitTag axis and not a domain difference.

The residue, measured

The post-SPEC-081 figure, not the before-picture — character already emits flat slots and says so in a comment:

transform lines
7 entity runes (work, spec, bug, decision, character, realm, faction)386
6 child sub-runes (characterSection, symbolMember, timelineEntry, …)~71
the family~450

Thirteen transforms that could be thirteen declarations.

What the mechanism has to express

Derived from the family, not invented:

A field becomes a named slot. The common case, and the identity case: field title becomes slot title. 58 of 93 refs keys need nothing but this.

A slot may be named differently from its field. description → blurb, sections → body. A rename is one token.

A slot is a value or a region, and a region has an element. This is the one capability that looks presentational and is not.

A value slot is already a single node — a span, a resolved heading. Nothing to wrap.

A region slot is N nodes, and N nodes need a boundary. The transform must emit it, because layout cannot: assembleWithBlocks resolves names out of mapDataNames and appends anything unnamed to rest (packages/transform/src/engine.ts:1417-1423). A prose body's contents are unnamed paragraphs and lists, so no layout entry can collect them into a created wrapper. There is nowhere else for the box to come from.

So the declaration states the kind, and the element defaults from it:

title: { from: 'title',    as: 'region', el: 'header' },
body:  { from: 'sections', as: 'region' },              // el defaults to 'div'
name:  { from: 'name',     as: 'value'  },              // no box

div is the neutral default — "these nodes are one region and I claim nothing else about them". header is stated when the grouping is semantically a header, which is a claim about meaning that survives any theme. SPEC-081's rule holds under a sharper reading: choosing to have a boundary is semantics; choosing how it looks is presentation. That removes 63 restatements of 'div' without changing a byte of output.

A slot may be omitted when empty. work's blurb appears only when resolved.description has content. Today descNodes.count() > 0 ? … : undefined.

The renderable's identity moves with the slots. createComponentRenderable is called inside the transform today, so rune, tag and property are stated there — hint is a section, work an article, both with property: 'contentSection'. With no transform there is nowhere else for them to live, so the declaration carries them. The rune name is not otherwise derivable: a plugin's loadPlugin knows it as the pkg.runes key, but createContentModelSchema runs inside the tag module, before that.

Sections arrive one of two ways. Resolved entries the rune renders itself (work, bug, decision — no emitTag), or child-rune tags already rendered (character, realm, faction — with emitTag). Both must be expressible, and the distinction is already declared by the content model's emitTag, so the labelling layer should read it rather than restate it.

Worked examples

The simple end — hint

Today, 17 lines:

transform(resolved, attrs, config) {
	const hintType = new Tag('meta', { content: attrs.type ?? 'note' });
	const body = new RenderableNodeCursor(
		Markdoc.transform(asNodes(resolved.body), config) as RenderableTreeNode[],
	);
	const bodyDiv = body.wrap('div');
	return createComponentRenderable({
		rune: 'hint', tag: 'section', property: 'contentSection',
		properties: { hintType },
		refs: { body: bodyDiv.tag('div') },
		children: [hintType, bodyDiv.next()],
	});
}

Declared:

export const hint = createContentModelSchema({
	sections: hintSections,
	attributes: {
		type: { type: String, matches: hintType.slice(), errorLevel: 'critical' },
	},
	contentModel: bodyOnly(),
	emits: {
		rune: 'hint',
		tag: 'section',
		property: 'contentSection',
		properties: { hintType: { from: 'attrs.type', default: 'note' } },
		slots: { body: { from: 'body', as: 'region' } },
	},
});

Note hintType is not an identity rename — the attribute is type, the property is hintType. A rename is one token either way, and the declaration makes it visible instead of buried in a variable name.

The complex end — work

64 lines become roughly this, with the properties channel in SPEC-140's data form:

emits: {
	rune: 'work',
	tag: 'article',
	properties: {
		id: '', status: 'draft', priority: 'medium', complexity: 'unknown',
		assignee: '', milestone: '', source: '', supersedes: '', pr: '', tags: '',
		created:  { from: ['attrs.created',  'file.created'],  default: '' },
		modified: { from: ['attrs.modified', 'file.modified'], default: '' },
	},
	slots: {
		title: { from: 'title',       as: 'region', el: 'header' },
		blurb: { from: 'description', as: 'region', omitWhenEmpty: true },
		body:  { from: 'sections',    as: 'region' },
	},
},

title states el: 'header' because "these nodes are this item's heading group" is a claim about meaning. blurb and body take the div default, which claims nothing beyond "one region".

Everything else work renders — the eyebrow bar, the twelve metadata rows with their labels, badges and sentiments, the content column — is already declared in config.metaFields, config.blocks and config.layout, and is untouched by this.

Three things the two examples show together. The declaration is flat — no entry nests another, per D2. The slot names come from the content model where they can (title, body) and are renamed where the output disagrees (blurb from description), which is the 62% identity figure in practice. And the hard part is already elsewhere: work is longer than hint because it has more attributes, not because it has more structure.

How many runes this reaches

Scanning the 124 candidate transforms (excluding snippet's deliberate throw and the structural layout / region / include) for constructs that D4's test rules out — unwrapping rendered .children, filtering rendered output, looping to build nodes, reading the raw AST node, or calling a content-inspecting helper:

transformslines
A — no disqualifying construct601,798
B — exactly one27920
C — two or more372,582

Treat 60 as a ceiling, not a forecast. The figure moved twice during calibration — an early pass counted if ( as disqualifying and returned 41, which wrongly excluded spec, character and realm whose only branch is the omit-when-empty case this spec declares; a later pass returned 65 and admitted card, which calls splitMediaBodyFooter(resolved.body) and so turns one field into three slots by inspecting node types — interpretation, not labelling.

A static scan cannot see every such case, so some of tier A will fail on contact. A realistic expectation is 40–50 transforms converting, with the 7 entity runes and 6 child sub-runes named in the acceptance criteria as the proving set.

Tier B is where the mechanism's boundary gets decided. Each of those 27 fails on one construct, so each is an argument about whether to add one capability or leave the rune imperative. D4 is the tiebreaker, and the answer should usually be to leave it — a mechanism that grows a feature per borderline rune stops being a mechanism.

Why this is a prerequisite for hosted rune definitions

A closure factory over inert data means a user-defined rune is data to validate, not code to sandbox: JSON arrives, it is checked against a schema, a closure is built. No eval, no VM, no worker isolation, no dependency surface. That is a materially easier security story than any codegen approach, and it is why the shape chosen here is the one a hosted renderer would want anyway.

But not because a user would write the declaration this spec adds. ADR-037 settles that users author composed runes (SPEC-145) and that this spec's slot declaration is the internal emit path — how first-party runes shed their transforms. This spec is a prerequisite for a different reason: a composed rune's frontmatter declares attributes, content, schema, metaFields, blocks and registers, which is this spec's and SPEC-144's vocabulary in full. The two paths share their whole declaration half and differ only in what emits. Make the declaration serialisable here and the hosted case inherits it; leave it imperative and no emit path can be authored as data.

It is necessary and not sufficient. "Declarative at every step" is a larger bar than this spec clears, and the remaining surfaces are worth naming so the question has a map:

SurfaceSitesStatus
transform135this spec
contentModel as a thunk (attrs) => …15a declarative form already exists — ConditionalContentModel's when over AttributeInCondition / AttributeExistsCondition / HasChildCondition — for 12 constants and 2 of the 3 branching sites; bento.ts:383 parameterises one model rather than choosing between two and is unreachable (ADR-036)
contentModel: { type: 'custom', processChildren }14undeclarable by design (SPEC-003)
itemModel pattern: /…/8serialises, but see below
config.postTransform4undeclarable by design (SPEC-081 non-goals)
config.styles[…].transform11a named-enum sibling already exists — and three helpers cover all 11 sites
plugin pipeline hooks9functions

Two of those deserve flagging now rather than on discovery:

A regex is code in the way that matters. pattern: /…/ crosses a JSON boundary as a string, but a user-supplied pattern in a hosted renderer is a ReDoS vector. Making itemModel user-definable needs either a timeout-guarded engine or a restricted pattern grammar — and itemModel is where the list-item authoring grammar lives (BUG-031), so it is not a corner that can be skipped.

The escape hatches are the boundary, and that is the good outcome. type: 'custom' and postTransform stay imperative by design, so a hosted rune is necessarily a subset of what a plugin can express. D4's family test then has a second job: it becomes the published boundary of that subset rather than an internal heuristic.

The engine config travels too — and it arrives pre-split

A rune is three payloads, not one: the schema, the RuneConfig the engine reads, and CSS. The config cannot be left behind, because block, modifiers and sections are the rune — no theme can supply them for a rune it has never seen. That sounds like it triples the problem. It does not, for three reasons.

The config is already the declarative half. Across the whole of RuneConfig there are exactly two function-typed fields: styles[…].transform (packages/transform/src/types.ts:301) and postTransform (:527). Everything else — structure, layout, contentWrapper, autoLabel, staticModifiers, editHints, projection, variants — is data today. And the plugin boundary is already untyped data: PluginThemeConfig.runes?: Record<string, Record<string, unknown>> (packages/types/src/package.ts:48). Config crossing a serialisation boundary is what plugins already do; it is the transform side that is imperative, which is this spec's subject.

The split between what the rune owns and what a theme owns is already drawn, enumerated and enforced. packages/transform/src/identity-fields.ts is, in its own words, "the one place the 'a theme restructures a rune, never redefines it' rule is expressed":

HalfFieldsOverride semantics
Identity (ADR-028)block, modifiers, sections, mediaSlots, frameTarget, universalAttributes, provides, schemaA theme override is dropped and reported as an IdentityViolation (merge.ts)
Presentationstructure, layout, contentWrapper, styles, staticModifiers, autoLabel, editHints, projection, variantsA theme may "hide, reorder, re-wrap and re-decorate"

So a user rune's config is not one payload but two with different semantics. The identity half must travel because nothing else can supply it; the presentation half travels as a default a theme may override, which is exactly what plugin theme.runes already is. No new position in the cascade is needed.

And most of the identity half should not be authored at all. sections is the slot declaration this spec introduces. modifiers follows from the attributes that feed properties. block should be the kebab-cased rune name — derived, not declared, for user runes especially: an author writing block: 'hero' would collide with a core rune's CSS, and deriving it makes collision-avoidance the same check as the rune-name uniqueness mergePlugins already enforces (packages/runes/src/plugins.ts:169). The residue that still needs declaring is small: schema, provides, and occasionally frameTarget / mediaSlots.

Superseded: what that implied for file layout

This subsection prescribed a two-file layout, and the premise under it is gone. It specified <rune>.rune.md for attributes, slots and the identity residue, <rune>.skin.md for the presentation config plus CSS — shaped as a theme override so it flowed through mergeRuneConfig with guardIdentity: true — and argued at length that CSS belongs with the presentation config, since a user rune has neither CI nor reviewers to police the seam that css-coverage.test.ts's 116-entry KNOWN_MISSING_SELECTORS allowlist polices here.

Three decisions removed the audience it was written for. ADR-037 makes the declaration on this page the internal emit path — how first-party runes shed their transforms — so no user ever authors one and there is no file to lay out: it is a TypeScript sibling to transform. ADR-035 is rejected, so there is no skin format for the second file to be written in. And SPEC-153 D9 settles the user-facing case as a single <rune>.md, because a composed rune ships no CSS (SPEC-145 D2) and therefore has no presentation half to separate.

What survives is the paragraph above this one, and it survives intact. The cut running through RuneConfig rather than between config and schema is a fact about identity-fields.ts and merge.ts, not about files, and it is what makes the engine config travel as two payloads with different override semantics. The block-should-be- derived argument stands on the same footing. Only the file names, the skin override path and the CSS co-location argument are void.

Recorded as superseded rather than deleted because the reasoning was load-bearing for a while and a reader meeting .skin.md in SPEC-153's history, in ADR-035, or in this spec's own open questions deserves to find where it went rather than inferring it.

One category the split does not cover: claims about content. schema is an identity field — a rune's schema.org table. SPEC-130 D5 accepted deliberately that nothing validates a table against schema.org, because "visibility replaces validation" and a wrong row is caught by a reviewer reading it. That holds while every rune ships in a package a human reviewed. It does not survive untrusted authorship: a user rune's table is a claim published about someone else's content with no reviewer in the loop. That needs its own answer, separate from the regex-safety and CSS-sanitisation questions already deferred.

Capabilities considered, and declined

Tier B above is 27 transforms that each fail on exactly one construct, so each is an argument for one capability. Recording the verdicts here so they do not have to be re-argued, and because D4's purpose is resisting exactly this growth.

CandidateRunesVerdict
unwrap — take a rendered node's children instead of the node7the named next candidate
pick — find a node by kind inside a field5belongs to the content model, not here
raw AST read — node.name, node.attributes8hard boundary; the escape hatch's job
filter — select rendered nodes by predicate3needs a predicate language
map — one value to one node, N times3iteration; SPEC-033 refused this

unwrap is the one to add first if any. datatable does wrapper.children.find(c => c.name === 'table'); recipe does ingredients.push(...node.children); form, conversation, storyboard, symbolGroup, symbolMember and budgetCategory each do a version of the same thing. It is one bounded operation — this slot is the contents of the field's rendered wrapper, not the wrapper — it stays flat, and it does not cross D2. Noted rather than included because the mechanism should prove itself on labelling alone first. It would also cost this spec its best example: recipe is D3's standing proof the escape hatch is needed, and unwrap is most of what recipe needs.

pick is the tempting one to get wrong. card, event, bentoCell, step and organization take one authored field and split it into several slots by node kind — splitMediaBodyFooter, extractMediaImage. But one field yielding several slots by inspection is SPEC-081's content interpretation: "deciding which authored child is the headline… it cannot be config." If it is ever wanted it belongs in the content model as a resolution feature, where interpretation already lives, not in the labelling declaration. Putting it here would move the boundary SPEC-081 drew.

The raw AST read is a boundary, not a gap. Those 8 read the unresolved node, before the content model has interpreted anything. Exposing it to a declaration hands user-defined runes the whole AST — the widest possible surface, and precisely what a hosted renderer must not grant. This is where type: 'custom' and transform earn their keep.

filter and map are where a slot table becomes a language. A predicate is a function, which D9 forbids, so they would need an expression grammar and an evaluator. Three runes each does not pay for that, and SPEC-033 already refused the general form: "five targeted additions, not a template language". If these are ever wanted, the answer is composition from primitives, not a bigger slot table.

One capability not on the list, rated above all of them

Declaring a slot expected, so its absence is loud. A slot that resolves to nothing currently vanishes silently — the same shape as BUG-028 (figure dropping non-image children), BUG-030 (a mixed field dropping authored tags) and BUG-031 (an em-dash date that never matches). Four of the six bugs filed alongside this spec are silent absence.

It eliminates no imperative code, so it fails SPEC-033's test outright and is not in this spec's criteria. It is recorded because a mechanism being introduced is the cheapest moment to make absence loud, and because the information already exists — the content model's optional flag says which slots are expected, and the generator knows when one came back empty.

Why a region needs a boundary — three consumers, not CSS

The wrapper reads like a CSS convenience and is not. data-name is an address, and three things resolve against it.

The editor. editHints is documented as "keys are data-name values … resolved at click time by the editor" (packages/transform/src/types.ts:440). Three sibling paragraphs each carrying data-name="body" means a click on the second resolves body and edits one paragraph as though it were the whole body.

layout. mapDataNames builds a Map keyed by name, last write wins. Duplicates collapse to one entry and the rest fall through to rest, appended at root.

projection. A theme can hide, group or relocate by data-name. If a prose body's paragraphs were individually named, a theme could hide or move part of an authored body — reshaping content it does not own.

That last one inverts the intuition. The flat-nodes-inside-a-named-box shape is what makes a region opaque to the theme: its children carry no data-name, so nothing in layout or projection can reach inside. The box is the boundary that protects authored content, not decoration around it.

The correspondence with editHints, tested and rejected

It is tempting to derive as: 'region' from editHint: 'none' and as: 'value' from 'inline' — one fact stated twice is exactly what this spec removes elsewhere. It does not hold.

Sidenote wraps its body in a div and declares editHints: { body: 'inline' }. So do PullQuote, ConversationMessage and AnnotateNote. Meanwhile Annotate declares body: 'none' — and both Sidenote and Annotate declare provides: ['prose'], so it is not tracking prose either.

The two are orthogonal. as is structural: how many nodes, does it need a boundary. editHint is a UX judgement: is this short enough to type into in place. A sidenote's body is a region either way; the editor is simply willing to edit it inline. Both stay declared.

Constraints carried forward from SPEC-081

Its "why the two layers stay" section is binding. Three are limits and one is an argument in favour:

The output must remain a semantic, framework-agnostic IR. One tree renders through svelte / astro / nuxt / next / react / vue / eleventy; the cross-page pipeline reads it pre-engine for the registry, extractTitle and breadcrumbs; the editor and refrakt inspect read it. Declarative labelling must produce the same IR, not shortcut toward presentation.

Static contracts get better. SPEC-081: "contracts/structures.json is derivable because structure is declared in config, not built imperatively." Labelling is the last thing contracts has to infer from a schema rather than read from a declaration.

No nesting in the labelling layer. SPEC-081's cautionary case is event, which pre-built a preamble <header> around headline and blurb — so layout could not address them individually, listing them in root did nothing and preamble silently appended last. A labelling mechanism that emitted nested nodes would reintroduce that bug class by construction. Flat bag only.

The two layers do not collapse. SPEC-081's first non-goal. Themes still run no code at the transform stage.

One node per data-name, and the generator must enforce it. This is an invariant of the existing output contract that nothing currently checks. createComponentRenderable's refs loop names every node in a cursor (component.ts:96-106), so a rune passing an unwrapped multi-node cursor silently produces duplicates — ambiguous for the editor, lossy for layout, and reachable by projection.

The fix is not to make mapDataNames a multimap. That would legitimise the ambiguous case rather than reject it. A declaration that would emit duplicate names is an error, and the same assertion is worth adding to the structure contract so it also covers hand-written transforms.

Decisions

D1 — it is not called projection, and it is not layout

Both words are taken and both mean something else in this pipeline. RuneConfig.projection is post-hoc surgery on a tree a theme does not own (types.ts:480), with group and relocate already deprecated as subsumed by recursive layout. layout is assembly — it consumes the flat slot bag. This spec names what produces that bag, and the vocabulary should say so.

D2 — it declares slots, never structure

A declaration may name a slot, source it from a field, give it a wrapper element and omit it when empty. It may not nest one slot inside another, order them, or create containers. Those are layout's, and the event bug is what happens when the boundary blurs.

D3 — the escape hatch stays, and is expected to be used

transform remains, exactly as contentModel: { type: 'custom' } remains for models that are "genuinely stateful and cannot be expressed declaratively" (SPEC-003). recipe is the proof it will be needed: it renders a <ul> and takes the <li> children, discarding the wrapper, does the same for <ol> steps, and filters blockquotes for tips. That is unwrapping and filtering, not labelling.

D4 — the family test is the acceptance criterion, not a rune list

Does every output slot come from exactly one resolved field, wrapped but not restructured? If yes, the rune is in the family and should need no transform. If it unwraps, filters, reorders or merges, it is not, and keeps one. Naming seven runes would invite the mechanism to grow until it covered the eighth.

D5 — the emitTag split is read, not restated

Whether a rune's sections arrive as resolved entries or as rendered child tags is already declared by the content model's emitTag. The labelling layer reads that rather than asking the author to say it twice — which is the same restatement this spec exists to remove.

D6 — the properties channel is SPEC-140's, not this spec's

Attributes becoming property metas is already being collapsed by SPEC-140's utility. This spec covers refs — the content slots. Both feed createComponentRenderable, and the naming collision SPEC-140 introduces (a metaFields utility beside a metaFields config key) should be resolved before either ships, independently of this spec.

D7 — no rune migrates until it can be proved output-identical

refrakt contracts --check plus npm run seo:baseline:check returning no diff is the gate per rune, as it was for SPEC-140. A rune whose declarative form does not reproduce its transform's output byte for byte has found a gap in the mechanism, and the mechanism changes rather than the output.

D8 — exactly one of transform or the declaration, never both

transform becomes optional; it is currently required. Neither present is an error. Both present is also an error, rather than "declare the common slots and let a transform patch the rest".

A half-declared rune is the hardest kind to reason about: a reader cannot tell from either half what the output is, and the generator and the hand-written code would both have partial claims on the same slots. SPEC-130's acceptance criteria already rejected this shape once — "the imperative form either still works or is fully migrated — not half of each, per rune". The same rule, for the same reason.

D9 — the declaration must stay serialisable; no function values

Every value in the declaration is data. No slot may take a function — not a computed default, not a predicate, not a formatter. This is what keeps the declaration transportable across a JSON boundary, and therefore what makes a hosted rune definition possible at all.

This is in tension with SPEC-140 as drafted, and the tension should be resolved rather than inherited. SPEC-140's metaFields utility takes a function for a computed default:

created: () => attrs.created || fileVars?.created || '',

That is a closure, so a properties spec written that way is not serialisable. The declarative equivalent is a fallback chain — a list of sources tried in order with a literal default — which covers the twelve fileVars-derived metas without a function value. Whichever form SPEC-140 ships, this spec's slot declaration takes the data form.

Non-goals

  • Reopening the SPEC-080 field / block / layout vocabulary — this builds on it
  • Moving any structural assembly out of layout, or reviving projection's deprecated group / relocate
  • Removing transform, postTransform or projection (D3)
  • Composing a rune from other runes with a Markdoc template — now SPEC-145, which carries its own questions about contract derivation, CSS ownership, cycles and schema.org, and has a different payoff (ecosystem reach rather than maintenance)
  • Letting a theme influence labelling — labelling is rune identity (ADR-028)
  • Specifying hosted rune definitions, or the validation, quotas and regex-safety story a hosted renderer needs — this spec only removes one of the blockers
  • Covering recipe, symbol, event or any rune that fails D4's test

Open questions

These came out of thinking about the file format a hosted renderer would accept for a user-authored rune. None of them block this spec — the mechanism here is a TypeScript sibling to transform, and the serialisable form only has to exist, not to have a file extension. They belong to the follow-on spec that defines that file, and are recorded here because that spec does not exist yet. The file-layout question that used to sit here is answered above, in the enforced identity / presentation split; what follows is what that answer left open.

ADR-037 has since retired two of these rather than answering them. Because a user authors a composed rune, which ships no CSS, "does CSS come with it?" no longer applies to a rune author at all. It was said here to apply to a theme author instead, "where ADR-035 already answers it" — but ADR-035 is now rejected, because after composition a theme's work is cross-cutting and a capability package shipping CSS is simply a package. So there is no skin format, the two-file .rune.md / .skin.md pair collapses to a single <rune>.md (SPEC-153 D9), and the discovery question is about one shape rather than a pair. What the schema question and the schema.org question lose in scope they keep in force; both are recorded below as they stand.

Where does the rune-definition schema live? Two JSON Schemas are already committed and drift-tested — packages/transform/refrakt.config.schema.json against SiteConfig, and packages/content/frontmatter.schema.json. A rune definition needs the same treatment against the slot declaration's interface, and config-schema.test.ts shows what that treatment costs: it asserts bidirectionally, so a field added to the interface fails the test until the schema gains it too, and the schema may declare no property the interfaces do not have. The question is whether the rune schema sits beside those two as a third top-level artifact or is generated from the same source. The sub-question of whether it spans both halves of a file pair or one per file is void with the pair (SPEC-153 D9): there is one file, so one schema — and understanding a serialised rune as a single-rune Plugin is what that one schema describes.

How is a definition discovered? A conventional directory scanned the way discoverPluginFixtures already finds plugin fixtures, or an explicit list in refrakt.config.json. Convention is cheaper to author and worse to debug; a declaration is the reverse. The choice also decides whether a hosted renderer can accept a rune as an upload without touching site config. Narrowed by ADR-037: this is now one file per rune plus its SPEC-102 fixtures, not a .rune.md / .skin.md pair, so the "is a skin file required?" sub-question is gone.

What stops a user rune from making false claims about content? The narrowest of the three and the one with no precedent to lean on. schema is an identity field, so a user rune's schema.org table travels with it and is published as an assertion about the site's content; SPEC-130 D5's trade — visibility in place of validation, with a reviewer as the check — has no reviewer here. Options span refusing schema on user runes outright, a closed allowlist of types, or shipping the ontology D5 declined. This is not the CSS-sanitisation question in another costume: bad CSS is visible on the page, a bad schema.org row is invisible by construction.

Interim answer, 2026-10-08 (SPEC-145 D25): user definitions may not declare schema yet; first-party ones may, because they are reviewed. The intended full answer is a mechanical check using @adobe/structured-data-validator against schema.org's published vocabulary, rather than an ontology refrakt maintains, pending a spike over the SEO baseline.

Answered — is metaFields identity or presentation? SPEC-158 D3 splits it by sub-key: metaFields.*.metaType is identity and a theme override of it is dropped, while label, sentimentMap and transform merge normally. The reasoning below is kept because it is the argument that answer rests on, and because the mechanism it needed — a guard that can protect a path with a wildcard segment rather than a whole top-level field — is SPEC-158's subject rather than a detail. The question as posed here is closed.

It is not in IDENTITY_FIELDS, so a theme may currently override a field's metaType — and metaType both selects chip-versus-bare rendering and is described as the field's "domain semantics". For a first-party rune that is harmless: the override ships in a package a reviewer reads. For a user-authored rune it means a theme can change what the author declared their data to mean. Compare the reasoning that guards schema: a theme able to restate a rune's schema.org type "would be able to change what a site asserts about its own content by changing its appearance". metaType looks like it belongs on the same side of that line; blocks and layout are unambiguously presentation and are correctly placed. Worth an answer rather than an accident, and it is cheap to settle while adoption is 20 runes out of 126.

SPEC-152 is where that question stops being theoretical. A composed plan entity's entire differentiation from its four siblings is its metaFields and blocks manifest — the layout is byte-identical five ways — so a theme retyping status would not be restyling a work item, it would be changing what a work item's status means. That audit therefore carries this as a prerequisite rather than an open question.

Answered, recorded above rather than here: whether the engine config has to travel (it does, split along IDENTITY_FIELDS, with the presentation half overridable).

Withdrawn rather than answered: whether CSS travels with the rune. This paragraph used to answer "it does, in the skin half, where the config generating its selectors also lives" — which contradicted the opening of this very section, where the skin half is recorded as gone. There is no skin half, and the question no longer has a subject on either path: a composed rune ships no CSS at all (SPEC-145 D2), and a declared rune is first-party, so its CSS lives where every other first-party rune's does.

Acceptance Criteria

  • A rune can declare its content slots: source field, slot name, wrapper element, and omit-when-empty
  • A slot whose name equals its field name needs no declaration beyond being listed
  • The declaration cannot express nesting, ordering or container creation (D2), and a declaration attempting it is rejected rather than silently partially applied
  • A slot declares whether it is a value or a region; a region emits a boundary element defaulting to div, and only a non-default element is stated
  • No declaration can produce two nodes carrying the same data-name; one that would is rejected at schema construction, naming the slot
  • The same one-node-per-data-name assertion is added to the structure contract, so it covers hand-written transforms too
  • A region's children carry no data-name, so neither layout nor projection can address inside an authored body
  • Both section arrival modes work, read from the content model's emitTag rather than declared again (D5)
  • work, bug and decision have no transform, and refrakt contracts --check plus npm run seo:baseline:check report no drift for any of them (D7)
  • character, realm and faction have no transform for the entity rune, under the same no-drift gate — these span the emitTag split, so covering both proves the mechanism
    • Deferred, not met in v0.39.0. WORK-617 was cancelled (owner's decision, 2026-10-07): on contact all three fail D4. realm / faction nest a named sceneImage inside the named scene wrapper, which the schema.org image row reads, and extractScene splits one field by node kind (pick). character filters its portrait to img. All three filter rendered children to the section rune and merge preamble runes into body only when no sections exist. Fitting them needs nesting, pick, filter and a cross-slot conditional, which this spec rules out. They go to the composed-runes work (SPEC-145 / SPEC-147). The emitTag side of D5 is proved by the mechanism's tests, not by a migrated rune.
  • buildSections and buildStoryContent are deleted, not merely unused
  • recipe still has its transform and is not contorted to fit (D3)
  • The emitted tree is unchanged as a semantic IR: the cross-page pipeline's registry, extractTitle and breadcrumb reads produce identical results
  • refrakt inspect and the generated reference describe a declaratively-labelled rune at least as completely as a transform-built one
  • The rune authoring guide documents the family test (D4) as the way to decide whether a new rune needs a transform
  • Declaring both transform and the slot declaration on one rune is rejected at schema construction, naming the rune (D8)
  • Declaring neither is rejected the same way
  • The generated transform is invoked at the existing call site, with no second code path through createContentModelSchema
  • The declaration carries the renderable's identity — rune, tag and property — since createComponentRenderable is no longer called from a transform
  • The slot declaration contains no function values, and a declaration carrying one is rejected at schema construction (D9)
  • The declaration round-trips through JSON.parse(JSON.stringify(…)) unchanged for every migrated rune
  • The slot declaration is sufficient to derive the rune's sections config entry, so the identity half of RuneConfig is not authored a second time alongside it
  • Total transform() line count across the tag files is recorded before and after

References

  • SPEC-080 — block and layout rune assembly model; act one
  • SPEC-081 — declarative structure assembly; act two, and the constraints this carries forward
  • SPEC-003 — the declarative content model that already holds the names
  • SPEC-140 — the transform boilerplate survey; owns the properties channel and the metaFields naming collision
  • SPEC-142 — inferring the transform signature; would type this declaration
  • ADR-028 — rune identity is not theme configuration
  • SPEC-033 — structure slots; where projection came from and why it is the wrong word here
  • SPEC-102 — the standardised fixture format; why fixtures stay sibling files
  • SPEC-130 — the schema.org table as identity, and the D5 trade that untrusted authorship breaks
  • SPEC-144 — declarative entity and edge registration; the cross-page half of the same programme
  • SPEC-145 — composed runes; the second authoring tier, which needs no CSS
  • ADR-035 — rejected; the skin format this spec's file layout was built on, and whose rejection superseded it
  • ADR-036 — name the pattern, do not open a language; the rule governing every gap this spec leaves
  • ADR-037 — users author composed runes only; why this spec's declaration is the internal emit path
  • SPEC-152 — the plan audit; where the metaFields identity question becomes load-bearing rather than theoretical
  • SPEC-158 — where that question is answered: metaFields.*.metaType is identity, label / sentimentMap / transform are not
  • SPEC-153 — delivery; D9's single <rune>.md, which supersedes this spec's two-file layout

figure silently drops every child that is not an image

BUG-028fixed

figure silently drops every child that is not an image

figure's transform builds its children from imgs — the nodes passing isMediaNode — plus an optional figcaption (packages/runes/src/tags/figure.ts:82-87):

runesfigurecontent-losscomposition
const childNodes: any[] = [...imgs];
if (captionTag) childNodes.push(captionTag);

isMediaNode admits <img>, <video>, and a scheme-resolved <svg> (tags/common.ts:200). Everything else an author puts inside {% figure %} — a code fence, a table, a nested rune, a paragraph that isn't consumed as the caption — is resolved, transformed, and then discarded with no warning.

The content model advertises the opposite: body is match: 'any', greedy. So the rune accepts anything and emits almost none of it.

Steps to Reproduce

{% figure caption="My file" %}
{% snippet path="a.ts" /%}
{% /figure %}

Expected

Either the fence appears inside the figure, or the rune says it cannot hold one.

Actual

{ "name": "figure",
  "attributes": { "data-rune": "figure", "typeof": "ImageObject" },
  "children": [
    { "name": "figcaption", "attributes": { "data-name": "caption" },
      "children": ["My file"] } ] }

The <pre> is gone. No error, no warning, no build diagnostic. The page renders a caption floating under nothing.

Notes

  • Reproduced with a {% snippet %} because that is the case that surfaced it, but the cause has nothing to do with snippet — any non-media child is dropped the same way. A fenced code block written directly in the body behaves identically.
  • typeof="ImageObject" is emitted unconditionally, so a figure holding anything other than an image also publishes a wrong schema.org type. That part is SPEC-130's channel, but the trigger is the same assumption.
  • This blocks the composition SPEC-062 recommends. Its CSS comment tells authors wanting labelled chrome to reach for {% codegroup title="…" %} precisely because figure cannot serve — but nothing says so at the point of use, and the content model implies it can.
  • Severity is major rather than minor because the failure mode is silent content loss. A rune that refused the child would be a smaller problem.
  • Two defensible fixes, and they are not equivalent:
    1. Emit the non-media children in body order, keeping imgs as the media slot. Makes {% figure %} a general captioned-container and unblocks figure-wrapped snippets, tables and diagrams.
    2. Warn and drop, keeping figure image-only. Preserves the current contract and the ImageObject type, and makes the limit visible. The choice is a product decision about what figure is, so it belongs in a spec rather than being settled in the fix.

Decision

Fix 1: figure is a general captioned container. Decided when scheduling v0.39.0. Non-media children are emitted in body order, and imgs stays the media slot. Together with WORK-615, which removes snippet's own figure wrapper, this makes {% figure %} the documented way to give a snippet, table or diagram a caption.

What the fix still has to settle, and record in its resolution:

  • The schema.org type. typeof="ImageObject" cannot stay unconditional once a figure may hold a code fence. One option: keep ImageObject when the media slot is the figure's only content, and emit no type otherwise. That choice belongs in the schema table (SPEC-130), and the baseline diff is reviewed, not regenerated silently.
  • Contracts and CSS. The structure contract moves. Lumina's figure styles need to hold up for non-media bodies.
  • Docs. The figure rune page states what it accepts, and SPEC-062's "reach for codegroup instead" advice is revisited.

References

  • SPEC-106 — image src scheme sugar; where isMediaNode's admitted set comes from
  • SPEC-062 — the snippet rune, whose standalone chrome this interacts with
  • SPEC-141 — removes snippet's own figure wrapper, which makes this the recommended path

Resolution

Completed: 2026-10-07

Branch: claude/v039-bug-028-figure PR: refrakt-md/refrakt#665

What was done

  • packages/runes/src/tags/figure.ts: every body child is kept, in body order. Media standing alone, or alone in a paragraph, is the media slot and is unwrapped as before. Anything else is emitted as written. The caption comes from caption= or the first paragraph with no media in it, and renders last. The transform records body: 'mixed' (pure data, field bag only) when the body holds more than media.
  • Schema type, decided in the table: the table could only select a row by attribute, so SchemaTable gains byField, which selects a row from the rune's field bag (lib/schema-table.ts; exclusive with by, validated). Figure's table is byField: 'body', rows: { mixed: {} }, with fallback ImageObject {image→contentUrl, caption→caption}. A mixed figure publishes no type and no properties. The properties go with the type, because a caption stamped with no typeof would attach to the page's typed ancestor. schema-row.ts, reference.ts and inspect surface byField.
  • CSS: figure's image rules in Lumina and the skeleton, and Lumina's figcaption rule, now target direct children only, so images inside a nested rune keep their own styling. Checked in a rendered Chromium page: fence, table and nested card render in order with the caption below.
  • Docs: runes/figure.md (what it accepts, a code/table example, structured data), runes/snippet.md (caption with figure or title with codegroup; codegroup kept as an option), and the plugin-authoring table reference (byField). Catalog description and VS Code snippet updated.
  • Tests: 6 inline snapshots of image-only figures recorded against the old code (commit edc0608) and unchanged by the fix, which shows the output is byte-identical. 6 behaviour tests, 5 of which fail on the old code (fence + table + nested rune in order with caption, interleaved media, spare paragraph, caption fallback, no type when mixed). byField unit tests in runes and cli.

Reviewed baseline / contract diff

  • The new fixtures figure.code and figure.mixed were recorded pre-fix in edc0608 (both ImageObject, children dropped). After the fix both are jsonLd: [] with no rendered annotations. That is the intended change: a captioned code block is not an image. The canonical figure fixture and every other fixture are unchanged. SILENT_FIXTURES in scripts/generate-seo-baseline.mjs records these two as deliberately silent rows of an emitting rune.
  • Structure contracts (both copies): figure's schemaOrg gains "byField": "body". Nothing else moved.

Notes / follow-ups (not done here)

  • packages/lumina/styles/runes/snippet.css's header comment and SPEC-062 §"Post-transform wrap" still say labelled chrome is delegated to codegroup. I left them alone because the snippet CSS belongs to WORK-615 (claude/v039-tree-order-preprocess), which removes snippet's figure wrapper. Until WORK-615 lands, a snippet in a figure renders figure > figure.rf-snippet > pre.
  • Pre-existing and kept: a figure with no media and only a caption paragraph still publishes a bare ImageObject {caption}. It could map to the empty row too; that is left as a separate decision.
  • Old quirk removed: a paragraph holding two images used to qualify as the caption fallback, so the images were duplicated into the figcaption. Media-only paragraphs are no longer caption candidates.