v0.32.0

Released September 10, 2026 · All releases

Narrowed rune schemas. The second half of : a rune's Markdoc schema offers only the universal attributes that can affect it, so refrakt reference, editor completion and Markdoc validation stop over-promising — without any of them loading theme config.

Changes

  • refrakt reference reports each rune's actual universal attributes (WORK-535)
  • refrakt reference card printed all 37 universal attributes under the heading "Universal attributes (available on every rune)". On most runes several of them do nothing — the CLI stated as fact something false about the rune it was describing, which is the symptom that opened SPEC-125. Unlike language-server completion, this did not fall out of the schema narrowing for free: the line was a separate code path printing the static universal set wholesale.
  • Each rune now reports what it carries, and names the reason for what it does not:
  • 51 of the 55 runes in this repo's reference had at least one false claim before.
  • Explaining, not just omitting. The alternative was to let an inapplicable attribute vanish silently, which matches the schema and is simpler. But reference output is a teaching surface — it is what create-refrakt writes into AGENTS.md for coding agents — and "why is reading on textblock but not on grid?" is exactly the question a bare omission leaves unanswered.
  • The schema stays authoritative. Availability is read off the rune's own attribute list; the applicability rule is consulted only to explain an absence, and an axis the schema carries is never reported unavailable whatever the rule says. A tool that contradicted the rune it describes is the bug being fixed, so the reconciliation is pinned by a test across every core rune rather than left implicit.
  • --format json gains attributes.universalUnavailable ({ axis, reason, attributes }[]) alongside the existing narrowed attributes.universal, so machine consumers get the same answer as the human output. The hoisted Universal Attributes section in reference dump now reads as the shared vocabulary rather than a blanket promise.
  • No theme config is loaded to produce any of it. ReferenceContext carries schemas, not configs — the right shape, because applicability is rune identity (ADR-028). The posture that explains an inline rune's absences (badge is inline; a structural reason would be true and beside the point) is recorded on the schema at construction, next to the join tables that answer the same question.
  • Close the drift between refrakt.config.json's JSON Schema and the config types, and fix refrakt config migrate stranding site fields
  • The published schema is hand-maintained and nothing guarded it against the RefraktConfig / SiteConfig interfaces, so it had fallen behind in both directions. Editors validating against it were missing autocomplete for real fields, and rejecting two that are valid:
  • Absent from the schema entirely — the project-level xrefs and fileRoots, and the site-level entityRoutes, locale, and strings.
  • Actively rejected — sandbox.dir (only the deprecated examplesDir alias was allowed, so the current spelling errored) and routeRules[].entity, which combined with additionalProperties: false made SPEC-092's convention-based entity typing a validation failure.
  • Advertised but dead — a top-level tints, dropped from RefraktConfig in SPEC-053. The normalizer deliberately refuses to mirror it, so a value written there is silently ignored at runtime.
  • packages/transform/test/config-schema.test.ts now guards this. Rather than mirroring the field list in a literal — which only catches drift if someone remembers to update it — it reads the interfaces out of @refrakt-md/types and asserts coverage in both directions, so a field added to SiteConfig fails until the schema describes it.
  • refrakt config migrate fix. The command kept its own hand-copied SITE_FIELDS array, which had drifted from the authoritative one in config-normalize.ts. The two describe inverse operations over the same set — the normalizer mirrors site → top level, the migration moves top level → site — so the divergence left search, repoUrl, and repoBranch behind at the top level of a migrated config, where nothing reads them once the file is in nested shape. It also moved tints, which the normalizer excludes on purpose. SITE_FIELDS is now exported from @refrakt-md/transform/node and consumed by the CLI, so both directions stay in step; the list is already type-checked with satisfies readonly (keyof SiteConfig)[].
  • No runtime behaviour changes for configs that were already valid — the schema is used for editor validation, and the top-level tints removal can't invalidate an existing file because the top level does not set additionalProperties: false.
  • Make prominence reach every rune, and ramp it relative to the rune's resting title size (WORK-538)
  • prominence re-points --rf-title-size, which [data-section="title"] reads. Eight rune stylesheets set font-size on their title element directly — and rune CSS is imported after dimensions/ at equal specificity, so the rune rule won and the variable never arrived. On those runes the axis did nothing at all: measured in a browser, a section headline moved 20→24→30→40px across the scale while a hero headline sat at 52px at every value. {% hero prominence="display" %} was a silent no-op, on the rune an author is most likely to write it on.
  • Two changes fix it.
  • The chain is split. prominence now publishes --rf-prominence-size and the title reads var(--rf-prominence-size, var(--rf-title-size, …)). One variable used to carry both the density default and the author's override, which meant a rune could not state its own resting size without also silencing prominence. Now it can: the eight runes declare --rf-title-size on their root, and the override still wins. density.css clears --rf-prominence-size at every rune root so an ancestor's prominence cannot leak into a nested rune's title.
  • The ramp is relative, not an absolute type scale. Fixed steps (xl / 3xl / 4xl) only read as a register when a rune rests at the density default — on a hero, which rests above the whole scale, display made the title smaller; on a bento cell, which rests below it, quiet made it larger. The steps are now the ratios those fixed sizes had to the 2xl resting size (×5/6, ×5/4, ×5/3), so the axis always moves in the direction its name implies.
  • What changes on screen. No rune's resting size moves — nothing that never asked for prominence is restyled. At full density the ramp is byte-identical for the 28 runes where the axis already worked (20/24/30/40) and newly effective on the eight where it was inert. At compact and minimal density the ramp now scales with the density rather than ignoring it: a compact rune's display is 33.3px rather than 40px. That is a visible change wherever prominence is set on a nested or compact-density rune, and it is the intended correction — a compact rune's display headline should not match a full-density one.
  • hero's raw 3.25rem is now a named resting size rather than an inline magic number. It stays a Lumina-local value rather than becoming a --rf-text-5xl token: the type scale is a typed contract every theme must satisfy (SPEC-048), and one rune's one-off does not belong in it.
  • A new test derives the title-role selectors from config and fails if any rune stylesheet pins a title font-size outside the chain, so the axis cannot silently go inert again.
  • Warn instead of silently dropping reading, content-place and scrim-strength (WORK-536)
  • Of the gated universal axes, four reported when a request was dropped and three said nothing. Each was silent for a different reason:
  • reading always resolved and published its register as state; the value only became data-reading once child assembly found an element with section role body. On a body-less rune it vanished with no code path aware anything had been asked for. Now warns — but only on an explicit reading=, since every unmarked block in a build resolves to the ui default and warning there would make the channel worth ignoring.
  • content-place was gated by appliesTo on an axis that a rune without the matching config modifier never sets, so the facet never ran and never got the chance to complain. It now also runs on the bare attribute, purely to warn.
  • scrim-strength in cover mode was neither honoured nor consumed: cover's scrim meta list omits it, so it was dropped from the styling and leaked its raw <meta> tag into the rendered tree. Now warned and consumed.
  • Each goes through the facet warning collector with a dedupeKey, so it reports once per build rather than once per rune instance.
  • Fixes an over-narrowing regression in the same release. WORK-534 filed the scrim* family under the cover axis, which is gated on a declared media-position modifier — so narrowing removed scrim, scrim-type, scrim-strength, scrim-blur and scrim-tone from 82 of 83 runes. That was wrong: the background layer reads all five and raises itself on any rune, and cover mode does not enable the scrim, it reroutes it to the media well. Verified against the engine — a bare { block: 'grid' } config with scrim="bottom" produces a full scrim overlay. The family now belongs to the bg axis, where the facet that implements it lives, so {% textblock scrim="bottom" %} validates again. cover owns no author-facing attribute and is still reported per rune, like density and content-place.
  • This is why the work item's third case changed shape: scrim* on a rune that cannot enter cover mode is not dropped at all, and the genuine defect runs the other way.
  • Schema narrowing catches authored attributes at validation time; these runtime diagnostics cover the path it cannot see, where scoped defaults and embed overrides (ADR-027) apply attribute bags to runes that never spelled the attribute out.
  • Breaking: rune schemas now declare only the universal attributes that can affect them (WORK-534, SPEC-125 Phase 3)
  • createContentModelSchema merged all 37 universal attributes onto every rune unconditionally. Several are structurally inert on most runes: reading and dropcap need a body section role, prominence a header-ish one, the frame* family a media surface, the cover scrim* family a media-position modifier. Authoring tools read the schema, so they promised every attribute on every rune — and the engine then dropped the inapplicable ones, three of them in total silence.
  • Schemas are now narrowed at construction: 84 tags lost 1,651 attribute slots between them.
  • What breaks. Writing a gated attribute on a rune that cannot honour it moves from a silent no-op to a Markdoc validation error. {% grid reading="prose" %} used to build and do nothing; it now fails with Invalid attribute: 'reading'.
  • The break is narrower than it sounds, because the attribute never did anything in the first place — a build that starts failing was already not getting the behaviour it asked for. A scan of all 986 markdown files in this repo found 25 uses of a gated universal attribute in live content and none that a narrowed schema rejects. The five that would be rejected were all inside documentation code fences, and were corrected in 0.31.0. Downstream content the scan cannot see is what this note is for; the fix in every case is to delete the attribute, or to move it to a rune that has the structure it needs.
  • 0.31.0 also removed the sharpest edge in advance: the runes an author is most likely to have written reading on were six whose section roles were missing, and those now honour it rather than rejecting it.
  • SPEC-125 had deferred the choice between narrowing outright and a transitional minor that annotates instead of rejecting. The numbers above decided it — a transitional release buys nothing and costs another minor of the silent no-op it exists to replace.
  • Availability is now a declared rule, not a side effect of which constructor a schema used. resolveUniversalAttributes() takes three inputs — the rune's posture (auto | inline | configurator | none, a new non-overridable RuneConfig.universalAttributes field), its own join tables, and the attributes it declares — and answers axis by axis. The six hand-written schemas that never carried universal attributes are each assessed and recorded: badge/xref/icon are inline, tint/bg supply an axis to their parent rather than carrying one, and expand is recorded as a legacy gap rather than passing as a decision.
  • The schema and the structure contract cannot disagree, because they are one derivation: both call UniversalAxisFacet.describeForRune, and a test compares the narrowed schemas against the contract's unavailable map across all paired runes in core and the nine plugins. That agreement is the point — an author told one thing by refrakt reference and another by Markdoc validation is the exact failure SPEC-125 exists to remove.
  • Language-server completion narrows for free, with no theme config loaded on the completion path — applicability is rune identity (ADR-028), answerable from the schema alone.
  • Transform output is unchanged. This changes what may be written, not what is emitted.
  • Breaking: reading and dropcap gate on a declared prose capability, not the body section role (WORK-537, SPEC-125 Phase 4)
  • The body role carried two meanings that were merged by accident of timing. Structurally it means "the rune's main content region", and Lumina keys layout and density off it. SPEC-108 later reused that declaration as a proxy for an editorial fact it never stated — "prose that a reading register applies to".
  • The proxy holds for most of the 32 body-role runes and breaks for a real minority. A datatable's body role is on its <table>; a showcase's is on its viewport. Once schemas narrowed (WORK-534), {% datatable reading="prose" dropcap=true %} would have validated and stamped a drop cap onto a table.
  • What changes. reading and dropcap are no longer offered on Api, DataTable, Form, Showcase and Symbol — writing either is now a Markdoc validation error, and a value arriving by another route (a scoped default, an embed override) is dropped with a warning naming the reason. The other 27 body-role runes are unaffected: Blog, Card, TextBlock, PullQuote, Sidenote, Lore, the storytelling entities and sections, the plan runes, and the rest all declare prose and keep both attributes.
  • Removing the role would have been the other fix, and it is the wrong one. A datatable's table genuinely is its main content region, and Lumina styles that role — dropping it would change how the rune renders for a reason unrelated to rendering. Every one of the five keeps its body role and every [data-section="body"] stylesheet is untouched.
  • The gate is declared, not inferred. A facet states what it needs (UniversalAxisFacet.requires) and a rune states what it provides (RuneConfig.provides), so the next axis needing a content capability reuses this instead of inventing another bespoke field. provides is a non-overridable identity field for the same reason sections is: it decides what an author may write.
  • Default-off — a rune must say it bears prose. That is only safe because schema narrowing landed first: a forgotten declaration is no longer silent, because the attribute is not offered and refrakt reference names the reason.
  • Also fixed: MusicPlaylist, the schema.org alias of Playlist, was a bare { block } config stub, so {% music-playlist %} rendered with none of the five data-section attributes its primary emits despite being the same schema and the same output tree. The audit surfaced it through the prose capability; the section drift was the same bug a layer down. It now carries the same join tables.
  • Also fixed: the facet warning collector deduped on a bare key, so two different diagnostics that both key on the rune name silenced each other — frame and content-place already collided this way, a warn-once swallowing an unrelated warning for the rest of a build. Keys are now namespaced by warning code.
  • Documentation: move author-facing pages out of the Extend handbook, and give the entity registry an author-facing home
  • The /docs vs /extend split was drawn by subsystem rather than by audience, so several pages describing what you type in a Markdown file lived in a handbook whose own introduction tells content authors to go elsewhere. extend/variables.md was the clearest case — its description reads "The author-facing variable surface", and it wasn't in the Extend sidebar at all.
  • Moved to /docs/authoring/, with redirect stubs at the old URLs: content variables, partials, page sections (linked from ~19 rune reference pages), nav slug resolution, and rich menubar panels. The i18n overview moved to /docs/configuration/i18n, next to the locale and strings fields it documents.
  • New: Entities. collection, aggregate, relationships, and expand all used to link into the plugin-authoring pipeline reference to explain what an entity is — a page that opens by describing itself as a build-time plugin mechanism. Authors needed the concept, not the implementation. The four runes now link to a page that covers what the registry holds, where entities come from (including type in frontmatter, which is how the rune catalog works), and how the query runes relate.
  • New: Entity routes. entityRoutes is site configuration, but it was documented only inside the pipeline hook reference — so the field was invisible to anyone reading the configuration docs. It now has a full reference there, and the pipeline page points at it.
  • Split rather than moved: the tint cascade is now frontmatter semantics for authors and the resolution function and SSR contract for theme developers. Both audiences genuinely need it, at different depths.
  • No content was duplicated — each page has exactly one home, and the two handbooks cross-link at their boundaries. Every internal link was verified against the built site.
  • Serve every published JSON Schema URL, fixing the 404 that scaffolded projects' $schema pointed at
  • create-refrakt writes a $schema URL derived from its own package version (https://refrakt.md/schemas/vX.Y/refrakt.config.schema.json), but the docs site only ever served one hardcoded version — v0.11. Every project scaffolded from v0.12 onward pointed at a URL that did not exist, so new projects got no editor validation or autocomplete at all.
  • The theme-token schema had it worse: create-refrakt scaffolds https://refrakt.md/schemas/vX.Y/theme-tokens.json for preset authoring, and there was no route for it at any URL. Its own $id claimed https://refrakt.md/schemas/theme-tokens.json, which also 404'd.
  • The versioned route is now generated rather than hardcoded: schemas/[version]/ enumerates v0.11 through the version @refrakt-md/transform ships, for both schemas, plus the unversioned schemas/theme-tokens.json alias. Because the release workflow builds the site after the version bump, the release that publishes vX.Y now publishes /schemas/vX.Y/ as part of the same run — the coupling that was missing between the scaffold and the route.
  • Each endpoint stamps $id to match the URL it is served from. The schema file bundled inside this package now carries the unversioned $id (https://refrakt.md/refrakt.config.schema.json) rather than a hardcoded v0.11, so the packaged copy can't go stale between releases.
  • Versioned URLs are stable addresses, not frozen snapshots — every version serves the latest schema body, which is benign because schema changes are additive. site/content/docs/configuration/schema.md now says so plainly instead of promising per-release freezing.
  • Fix: character dropped prose written directly in its body (BUG-003)
  • The paragraph never reached the output. The body slot rendered as an empty <div data-name="body"> — no warning, no error, the content simply gone. realm and faction, which share Character's exact shape, handled the same markup correctly.
  • Two things were wrong. The content model named its greedy prose field header and transform never read it, so the prose was resolved and discarded. And the body slot was built from the items cursor, and only when the character had no sections — so even that path could not produce lead prose alongside sections. Character now uses buildStoryContent, the same helper its two siblings use.
  • What changes on screen. Prose that currently vanishes starts rendering, in the character's body slot above its sections. A character that has only sections is unaffected — no body slot is emitted, as before. The body slot also moves ahead of sections in the content column, matching realm and faction: the two were previously mutually exclusive, so the order never mattered.
  • This also makes Character's body section role meaningful in practice. The role was declared correctly in 0.31.0 and data-reading / data-dropcap landed on the slot, but there was never any prose in it for a reading register to style.

Theme-independent attribute applicability

SPEC-125shipped

Theme-independent attribute applicability

Make a rune's universal-attribute applicability a rune-structural fact, visible at schema-build time, so authoring tools stop promising attributes that cannot do anything.

Governed by ADR-028, which settles the principle: applicability is rune identity, never theme configuration; a theme may decline to style an axis but never to suppress its emission.

runesattributesschemaconfigthemeclilanguage-serverdx

Problem

refrakt reference card prints, today:

Universal attributes (available on every rune): tint, tint-mode, bg, width, reading, dropcap, spacing, inset, elevation, prominence, reveal, stagger, frame, …

On a card, reading, dropcap and prominence do nothing. The CLI states as a fact something false about the rune it is describing. The language server's completeAttributeNames has the same blind spot: it iterates the rune's Markdoc schema attributes with no applicability filter, so the editor autocompletes prominence on runes where the engine will drop it with a warning.

Both are correct from where they stand — schemas are theme-agnostic and applicability currently lives in ThemeConfig.runes, which neither loads.

WORK-527 made the scale visible for the first time: 686 per-rune unavailability entries across 132 runes. 111 runes can never carry data-reading; 92 can never carry data-prominence.

Four of the seven gated axes warn when they are dropped. Three — reading, content-place and the cover scrim* family — are entirely silent.

Goals

  • A rune's Markdoc schema offers only the universal attributes that can affect it.
  • refrakt reference, editor completion and Markdoc validation become correct without loading theme config.
  • Applicability cannot be changed by a theme.
  • Universal-attribute availability is answered by a declared rule, not by which schema constructor a rune happened to use.
  • Every inapplicable attribute an author writes produces feedback — no silent no-ops.
  • Applicability gates are declared capabilities, not structural facts reused as proxies. See The governing rule below.

Non-goals

  • Letting a theme suppress attribute emission. Explicitly rejected in ADR-028.
  • Restricting what a theme may do with layout, structure, styles, contentWrapper, staticModifiers, autoLabel, editHints or projection.
  • Migrating the six hand-written schemas to createContentModelSchema. See Phase 3.
  • Changing any HTML the engine emits. This spec is about what authors may write and what tools promise; transform output is unchanged throughout.

Phases

The ordering is load-bearing: the data audit must land before schemas narrow, or wrong exclusions get frozen into validation errors.

Phase 1 — Audit and correct the section-role data

Applicability is derived from sections, and sections is currently both incomplete and, in places, over-declared. The audit runs in both directions — a role that is missing wrongly denies an attribute, and a role that is present wrongly offers one. Both become hard facts once schemas narrow, so both are worth settling first.

Direction 1 — roles that are missing

Six runes declare a body slot in their layout but never map it to the body role: Card, BentoCell, Character, Realm, Faction, Playlist.

Card is the clearest case. Its config is:

sections: { media: 'media' },
layout: { root: ['media', 'content'],
          content: { tag: 'div', children: ['eyebrow', 'body', 'footer'] } }

The content model is literally "body (optional, repeatable any block)" — body text is the main thing a card contains. sections simply never declares it, so applyBemClasses never sets data-section="body", so data-reading never lands. That is a config gap, not a statement that cards have no body.

Six more declare a title/header slot with no header-ish role: AccordionItem, BentoCell, CharacterSection, RealmSection, FactionSection, ItineraryDay.

The remaining 105 runes have neither a body role nor a body slot and are genuinely bodyless (badges, icons, table cells) — those exclusions are correct and must not be "fixed".

Each of the ~10 runes needs a per-rune judgement: is the missing role an oversight, or is the slot deliberately not a semantic section? Adding a role is not output-neutral — it adds data-section and enables data-reading — so each change needs its own justification and test.

Direction 2 — roles doing double duty

Three runes map a body role from a slot not named body:

RuneMapping
Blogcontent → bodyprose
Showcaseviewport → bodya component preview surface
DataTabletable → bodya table

Under Phase 3 the latter two would have reading and dropcap offered in their schemas, so {% datatable reading="prose" dropcap=true %} would validate and stamp a drop cap onto a table.

The role is not the problem, and removing it is not the fix. DataTable's table genuinely is the rune's main content region, and Lumina styles that role directly — styles/dimensions/sections.css:42 and styles/dimensions/density.css:45 both key on [data-section="body"]. Dropping the role would change how these runes render, for a reason unrelated to rendering.

The real problem is that body carries two meanings that were merged by accident of timing:

  • Structural — "the rune's main content region". Its original purpose, consumed by the dimension stylesheets.
  • Editorial — "prose that a reading register applies to". Added later by SPEC-108, which reused the existing structural declaration as a proxy for a capability it never declared.

The proxy holds for most of the 21 runes with a body role and breaks for a real minority: DataTable, Showcase, Form, Api, Symbol. (Only four runes set defaultReading at all — Sidenote, PullQuote, TextBlock, Lore.)

Separating the two meanings is therefore not a data fix and does not belong in Phase 1. It is a small design change, and it is sequenced into Phase 4 for a reason given there.

Guard against recurrence

A lint or test that flags a missing role (Direction 1) belongs here, so that drift cannot silently return once corrected. Direction 2 is not a lint target — those roles are correct.

The governing rule: declare capabilities, do not infer them

The lesson generalises past reading, and is worth stating because it predicts where the next axis will go wrong. All four applicability gates today:

AxisGateKind
cover, content-placerune declares the media-position / content-place modifierexact
prominencea header-ish roleproxy — sound
frame, substratea media role, or an explicit targetproxy — mostly sound
reading, dropcapa body roleproxy — lossy

The one exact gate is the one whose declaration was made for that purpose. Every proxy gate reuses a structural fact declared for something else, and the lossy one is where the correlation breaks.

Rule. When an axis needs a capability, have runes declare that capability. Infer it from an existing structural fact only when the fact and the capability are genuinely the same thing. A media role really is a frame target — that inference is identity. A body role merely usually contains prose — that is correlation, and correlation drifts.

Applied at SPEC-108 time this would have caught the reading case. It is the test to apply to the next universal axis anyone adds. If a second axis turns out to need it, this rule is a candidate for promotion to its own ADR.

Phase 2 — Lock applicability as rune identity

Extend the IDENTITY_FIELDS guard in packages/transform/src/validate.ts — today enforced only against SPEC-091 variant deltas — to the theme override path in mergeRuneConfig. block, modifiers and sections become non-overridable on every merge path.

This is expected to be a no-op in practice (Lumina overrides none of them), which makes it cheap to land early and independently.

Where the applicability data lives — resolved as (b)

Phase 3 needs the gating facts reachable from schema-build time, and today they live in RuneConfig, which the schema layer never sees. createContentModelSchema merges all ~37 universal attributes unconditionally, at module scope, with no structural input at all:

// packages/runes/src/lib/index.ts:384
Object.assign(attributes, universalAttributes);

Two shapes were considered. The module graph decides between them.

(a) Keep RuneConfig as the source and have the schema layer consume it — rejected. The dependency direction is config → tags, uniformly. Core's config.ts imports four sentinels from tag modules (BREADCRUMB_AUTO_SENTINEL, NAV_AUTO_SENTINEL, PAGINATION_AUTO_SENTINEL, XREF_RUNE_MARKER); no core tag imports config, and no plugin tag imports its config either — 0 of 9.

For a tag module to import config would invert that direction and create a cycle in core. Because createContentModelSchema is called at module scope, the tag would observe coreConfig still uninitialised mid-cycle — a crash, not a subtle bug. The cycle-free variant (narrow later, at catalog/registry assembly) works but must run at every assembly point — the core catalog, each plugin's runes record, the merged tag set — and hands config to every consumer. That is the "teach the consumers to read config" model ADR-028 already rejected, reached by a different route.

(b) Declare the fact in the tag module and let config reference it — adopted.

// tags/card.ts
export const cardSections = { media: 'media', body: 'body' } as const;
export const card = createContentModelSchema({ …, sections: cardSections });
// config.ts — already imports from tags/
Card: { block: 'card', sections: cardSections, … }
  • createContentModelSchema has the fact in hand at construction, so narrowing is local: no config lookup, no cycle, no build-order question.
  • The engine is unchanged — it still reads config.sections exactly as today.
  • No new import direction anywhere. Core already runs this way; plugins would start to, in the direction their code already permits.

Narrowing becomes correct by construction rather than by ordering discipline, which is the whole reason to prefer it over the workable form of (a).

Scope of the move. The join tables plus frameTarget: sections (~50 runes), mediaSlots (a handful), frameTarget (2). Mechanical, but it touches ~50 places across ten packages.

modifiers does not move. For the gates that read it, the schema already knows: cover and content-place gate on the rune declaring a media-position / content-place modifier, and those are author-facing attributes the schema declares anyway (refrakt reference card lists both). That is the exact gate from The governing rule above, and it is already schema-side.

The identity guard remains load-bearing. Under (b), config references the rune's declaration rather than owning it — but sections is still a RuneConfig field, so a theme could shadow it in the merged config. The engine would then use the theme's value while the schema used the rune's: silent divergence, exactly what this spec closes. The guard is required under every option, not just under (a).

The line to draw: join tables vs postures

The question is not "does sections move" but which fields are schema↔engine join tables and which are genuine theme postures. sections turns out to be the clearest case of the former:

Accordion: { sections: { preamble: 'preamble', headline: 'title', blurb: 'description' } }
  • Keys are the rune's own slot names, emitted as data-name by its schema transform. A theme cannot invent one — it does not control what the transform emits.
  • Values are a closed vocabulary the engine owns (header | preamble | title | description | body | footer | media, types.ts:341).

The theme owns neither side. All a theme can do is rewire an existing pair — exactly the capability ADR-028 removes.

Its only emission consumer is applyBemClasses (engine.ts:560), which turns the role into data-section, plus data-reading/data-dropcap on a body role and data-guest-fit on a media role. Lumina's CSS reads the emitted data-section attribute, never the sections config (styles/dimensions/sections.css and ~10 rune stylesheets), so moving the field touches no stylesheet at all. The theme's real relationship to sections is entirely downstream of emission: it styles roles, it does not define them.

Applying the same test to the fields that travel with it:

FieldShapeVerdict
sectionsdata-name → closed engine rolejoin table — move
mediaSlotsdata-name → closed vocabulary (portrait/cover/…)join table — same argument, move with it
guestFit'clip' | 'bleed' containment postureposture — stays; gates nothing
substrateTarget'media' | 'self', defaults to 'self'posture — stays; see below
frameTarget'media' | 'self'the exception — see below

substrateTarget gates nothing

It defaults to 'self', so substrate is applicable on every rune, and it is set on zero runes in the catalog. The only unavailability path — substrateTarget: 'media' on a rune with no media section — is a misconfiguration, not an authored intent, and cannot currently occur. It is a genuine theme posture ("put the pattern on the media well rather than the whole surface") and can stay in RuneConfig without splitting anything. This spec's earlier framing of it as a fuzzy applicability gate was wrong.

frameTarget splits frame applicability across two homes

Frame applicability resolves as config.frameTarget ?? (hasMediaSection(config.sections) ? 'media' : null). The type has no 'none', so frameTarget can only ever grant, never revoke.

It is set on exactly two runes — Figure and Showcase, both 'self', both with no media section. In both cases it grants frame applicability to a rune that would otherwise have none, and it means "frame me, because I am the media" — which reads as a structural fact about the rune, not a decoration.

That creates a concrete hazard if sections moves and frameTarget does not. Frame applicability would have two sources, one of them still theme-overridable, so a theme could add frameTarget: 'self' to a rune whose narrowed schema rejects frame=. Config would grant what the schema forbids — the same divergence this spec exists to close, inverted.

Three resolutions, to be decided in the placement work item:

  • (a) frameTarget moves with sections. Both become rune identity. Cheapest and most consistent; the migration is two runes.
  • (b) Narrow frameTarget so it can only redirect among surfaces the rune already has. Then Figure/Showcase would need a media section to be framed — awkward, since the whole rune is the media.
  • (c) Introduce an explicit "this rune is its own media surface" structural fact and retire frameTarget: 'self'. The most honest modelling; the most churn.

(a) is the decision. (c) is recorded below as a follow-on with an explicit trigger, because the evidence for it today is thin and (a) does not foreclose it.

Why (c) is deferred rather than rejected

The engine has one media model, and it assumes media is a sub-zone holding a guest: sections: { x: 'media' } declares the well, guestFit says how the well contains its guest, mediaSlots sizes it, findMediaZone() walks children for data-section="media", and frameTarget defaults to 'media' when such a zone exists.

Figure and Showcase do not host media — the image is the figure. There is no guest. That fact has no representation in config: the only way to express it is a side effect of frameTarget: 'self', which states the consequence ("frame chrome lands on my root") rather than the cause ("I am the media surface").

(c) would name the cause — e.g. mediaSurface: 'self', mutually exclusive with a media section role — so that every media-aware axis derives from one fact: chrome lands on the media surface, which is either the root or [data-section="media"].

The argument for it is that substrateTarget is already the second instance of the same escape hatch — same 'media' | 'self' type, same question, added independently, and it too never learned about these two runes. Today {% figure substrate="dots" substrate-target="media" %} warns "has no media section" and does nothing, on a rune that plainly has a media surface.

The argument against is that this is two runes and two axes, one of which is set on zero runes in the catalog. That is a duplicated pattern, not a demonstrated cost, and (c) carries a new config field, a vocabulary, its interaction with sections, both facets' resolution logic, docs, contract description and tests.

Trigger for revisiting: if a third media-aware axis needs a 'self' target, stop adding escape hatches and name the fact. Until then (a) holds, and the migration (c) would need is the same two runes either way.

Phase 3 — Narrow schemas and fix the consumers

With correct data and a reachable source, createContentModelSchema merges only the applicable universal attributes.

The rule must be declared, not inherited from a constructor. Six runes use hand-written schemas and therefore carry no universal attributes at all: icon, tint, bg, xref, expand, badge. That split is mostly principled and should be expressed rather than migrated away:

RuneReasonAssessment
xref, badgeinline: truecorrect — block axes are meaningless on an inline span
tint, bgconfigurator runes that supply axis values to their parentcorrect — a {% tint %} with its own tint= is circular
iconeffectively an inline glyphprobably correct; confirm
expandblock-level disclosureprobably wrong; reads as legacy

So the rule needs at least three inputs: inline-ness, configurator-ness, and rune-structural applicability. Migrating these six to createContentModelSchema is not required — four would end up declaring "no universal attributes" anyway.

Consumers then fall out:

  • refrakt reference stops printing "available on every rune" and reports the rune's actual set. No config loading.
  • Language server completion narrows automatically — it reads rune.attributes, which is the schema.
  • reading, content-place and cover scrim* gain the dev warning their four siblings already have, for the case where an attribute is written but the facet never runs. Still needed: schema narrowing catches authored attributes, not values arriving through scoped defaults or embed overrides (ADR-027).

Phase 4 — Separate the prose capability from the body role

Give reading and dropcap a declared gate instead of the lossy body-role proxy, per the governing rule above. The structural role and every stylesheet that keys on it are untouched.

Sequenced after Phase 3, deliberately. The design question is the default, and Phase 3 settles it:

  • Default-on (a body role implies prose unless a rune opts out) keeps the migration tiny, but preserves the lossy proxy as the default — so the DataTable bug simply recurs for the next non-prose rune.
  • Default-off (a rune must declare that it bears prose) is honest, but a forgotten declaration silently disables reading — which is today's failure mode returning.

Default-off is the right answer only once schemas have narrowed: with Phase 3 in place, a forgotten declaration is no longer silent — the attribute is not offered, and refrakt reference says so. Phase 3 converts the risk of default-off from an invisible regression into a discoverable one. Running Phase 4 first would forfeit that.

Shape. Express the gate as a capability declared by the facet and provided by the rune, rather than another hard-coded helper alongside hasBodySection / hasMediaSection / hasPageSectionHeader:

readingAxis.requires = 'prose'
TextBlock: { provides: ['prose'] }

Only one axis needs it initially, so this is close to the narrow fix in cost. It is worth doing as the general shape now because SPEC-124 already gave every facet a declaration point — requires sits beside contract and describeForRune, and flows into the structure contract for free. The alternative, a bespoke proseSection field on RuneConfig, solves the same case and leaves the next axis to invent its own.

The ~21 runes carrying a body role are audited once for whether they bear authored prose. Expected non-providers: DataTable, Showcase, Form, Api, Symbol.

Migration

Narrowing turns {% card reading="prose" %} from a silent no-op into a Markdoc validation error. That is the intended outcome — the author gets no feedback at all today — but it will break existing content.

Phase 1 removes most of the sting: the runes an author is most likely to have written reading on are exactly the six whose roles were missing. What remains is content targeting a genuinely bodyless rune, where the error is correct.

The transitional option from ADR-028's rejected alternatives is available if the break proves too sharp: keep schemas permissive for one minor and have refrakt reference and the editor annotate inapplicable attributes rather than reject them, narrowing in the following minor.

Decided: narrow directly, no transitional minor

Phase 1 has landed, so the impact is now measured rather than estimated. Every {% tag %} in the repo's markdown — 986 files across site/content, plan, the plugins and the create-refrakt templates — was scanned for the seven gated axes and each use attributed to the rune it sits on, then checked against the structure contract's unavailable map:

  • 25 uses in live authored content. 0 would be rejected after narrowing.
  • 5 would be rejected, all inside documentation code fences — illustrative examples, not content that builds.

The transitional minor exists to soften a break that does not materialise here, and it carries a real cost: it preserves the silent no-op as the default experience for another release, which is the thing this spec exists to remove. Narrow directly in Phase 3.

Two caveats the same scan turned up, both now fixed rather than migrated:

  • Three docs taught {% recipe reading="prose" %} as the "editorial-header composition" — the flagship example of this spec and SPEC-108. recipe has no body slot at all (its content is ingredients, steps and tips), so reading was always a no-op there and the composition only ever half-worked. The user docs now use blog, which maps content → body and carries the whole composition; SPEC-108 carries a correction note rather than a rewrite.
  • site/content/runes/bg.md documented {% bg src="…" scrim="bottom" %}, but bg is a hand-written schema that declares no scrim attribute, so that example was already a Markdoc attribute-undefined error with the scrim silently never applying. The scrim* family is universal and belongs on the rune the background sits behind; the doc now shows that form.

Neither is a migration concern — both were broken before narrowing. They are recorded here because the impact scan is what surfaced them, and because they are the same disease this spec treats: documentation promising an attribute the schema does not carry.

Acceptance criteria

  • The ~10 missing-role mismatches are individually assessed and resolved; each change carries its own test and the reasoning is recorded
  • A lint or test flags a missing section role, so Direction 1 drift cannot recur
  • reading and dropcap gate on a declared prose capability, not on the body role; the structural role and every stylesheet keying on [data-section="body"] are unchanged
  • The capability is declared by the facet and provided by the rune, so the next axis needing one does not invent a bespoke field
  • The ~21 body-role runes are audited for whether they bear authored prose; DataTable, Showcase, Form, Api and Symbol no longer offer reading/dropcap
  • IDENTITY_FIELDS is enforced on mergeRuneConfig, not only on variant deltas
  • The join tables (sections, mediaSlots) and frameTarget are declared in their tag modules and referenced from config, so createContentModelSchema has them at construction; the engine's read path is unchanged
  • modifiers stays in RuneConfig — the schema already declares the attributes its gates read
  • frameTarget moves with sections (resolution (a)), so frame applicability has a single source — no path by which theme config can grant an attribute the narrowed schema rejects
  • createContentModelSchema merges only applicable universal attributes
  • Universal-attribute availability is governed by a declared rule that accounts for inline-ness and configurator runes — not by which constructor a schema used
  • refrakt reference <rune> reports the rune's actual universal attributes and no longer claims "available on every rune"
  • Language server completion offers only applicable universal attributes, with no theme-config loading added to the completion path
  • reading, content-place and the cover scrim* family warn when dropped
  • The structure contract's unavailable entries agree with the narrowed schemas — the two derivations must not diverge
  • Transform output is unchanged except where Phase 1 deliberately adds a section role; those changes are enumerated
  • Migration path decided and documented; a breaking change carries a changeset
  • npm run build, the full repo suite, and refrakt contracts --check pass

References

  • ADR-028 — the governing decision
  • SPEC-091 — engine config variants; source of the identity-field rule
  • SPEC-124 — facet registry; gave each axis an exported vocabulary
  • WORK-527 — universal axes in structure contracts; the measurement
  • SPEC-108 — reading register and dropcap
  • SPEC-107 — elevation and prominence
  • ADR-027 — scoped attribute bags; the non-authored input path
  • SPEC-028 — rune output standards