v0.40.0

Released October 8, 2026 · All releases

The first composed rune. A rune can now be defined as a Markdoc template that places existing runes, instead of a transform written in code. Composition is proved on two storytelling runes, bond and character, beside the plugin's own versions, so nothing you render changes yet. Separately, sandbox's context attribute now takes effect: context="dark" gets the dark token set instead of the default one.

warning

Action needed for theme authors: a theme can no longer override a rune's sequence, a meta field's metaType, or a layout wrapper attribute that a rune's identity emits. Each is part of what the rune means, not how it looks. Such an override is now dropped and reported; the presentation keys beside it still apply.

Background: · ·

Changes

  • A RuneConfig may now omit block (SPEC-145 D2a). A block-less config gets no rf-* classes, on its root or on its named children, and never rf-undefined. Everything else the identity transform does still applies: modifiers render as data-* attributes, universal attributes and metadata blocks render, and data-rune-fields is stripped. refrakt contracts describes a block-less entry by [data-rune] and its data-* modifiers, with no BEM selectors. validateThemeConfig and refrakt plugin validate accept a missing block and still reject an empty one. refrakt inspect --audit and scaffold-css skip block-less runes, because they ship no CSS. No shipped rune drops its block yet, so existing output is unchanged.
  • A rune can now be defined by a composition template (SPEC-145). A definition is YAML frontmatter declaring the rune's input (tag, attributes, content, schema, registers, metaFields, blocks) and a Markdoc body that places the content model's fields into slots of existing runes: {% slot name="x" /%}, with fallback content, and {% slot name="xs" each %}…{% /slot %} with $each bound to the model's emitAttributes. $attrs.<name> and Markdoc's own {% if %} work in the template. The template renders at the rune's transform, after field resolution, so every placed rune runs its own transform unchanged. Slot-placed nodes carry data-slot in the output; a slot adds no element. The composed root carries the rune's own data-rune and a generated block-less config, and a node-sourced registers value is copied into the field bag for Phase 2.
  • Every bad definition fails at load or construction, naming the rune and what was rejected: a preprocessor tag in the template, a second emit path, an unplaced field, an unknown or repeated slot, each on a single value, an undeclared $each or $attrs field, a cycle, a peer schema type, a placed rune missing its required parent, and CSS.
  • PluginRune carries exactly one of transform or template (SPEC-153 D11); transform is now optional on the type. refrakt contracts records a composed rune's slot names and expansion. No shipped rune is composed yet, so contracts and structured data are unchanged.
  • New exports from @refrakt-md/runes: defineComposedRune, composedPluginRune, pluginRuneSchema, checkComposedCatalog, collectCompositions, compileComposition, compositionFor, parseCompositionDefinition, composedRuneConfig, composedTypeName, checkCompositions, SUBORDINATE_SCHEMA_TYPES, validatePlugin. createContentModelSchema takes a template option.
  • data-owner and data-slot now survive a primitive's transform, ahead of composed runes (SPEC-145 D10a). The page pipeline declares both attributes once, when it assembles the Markdoc config, on every node and tag schema. No rune declares them itself, so authoring tools do not offer them. The schema-table resolvers admit a node by data-owner plus data-slot, and a primitive's own data-name on that node still resolves for the primitive. releaseOwnedNodes strips data-owner and keeps data-slot. Nothing sets the markers yet, so rendered output and structured data are unchanged.
  • New exports from @refrakt-md/runes: declareSlotMarkers, declareSlotMarkersOnNodes, OWNER_ATTR, SLOT_ATTR.
  • sandbox's context attribute now takes effect. The sandbox transform never emitted it, so the design plugin always resolved a sandbox to the default design-context token set: context="dark" got default, and an undefined scope got default with no warning. context is now a field in the rune's data-rune-fields bag. A named scope gets its own token set, an unknown scope gets none and raises Sandbox references design context "…" which is not defined on any page, and a sandbox with no context still gets default. The editor's block preview reads the same field, so it matches the build.
  • A composed rune's template can place a declared meta block with {% metablock name="…" /%} (SPEC-145 D7). A composed rune has no layout, so until now a definition could declare metaFields and blocks in full and get nothing rendered. The tag marks where the block goes. The engine fills it from the rune's own config and values, anywhere in its template's subtree, using the same renderer layout projection uses, so the block renders identically and a theme's blocks override reaches it unchanged.
  • The tag is template vocabulary only: on a page it is an undefined tag. Each of these is rejected when the definition is built, naming the block: a name that matches no declared block, a block placed twice, and a placement inside an each slot. A block whose fields all resolve empty emits nothing.
  • Every attribute a metaFields entry reads is now a modifier of the generated config, so it also renders as a data-* attribute, as on a tree-owning rune. METABLOCK_ATTR and METABLOCK_OWNER_ATTR are exported from @refrakt-md/transform. No shipped rune is composed, so existing output is unchanged.
  • The identity guard is path-granular (SPEC-158). IDENTITY_FIELDS now holds config paths, and findReservedFields resolves a wildcard segment. Three protections join the existing eight keys, each dropped and reported rather than thrown:
  • A theme override of sequence is ignored — whether an ordinal is information (a playlist's track numbers) is rune identity. sequenceDirection stays overridable.
  • A theme override of metaFields.<field>.metaType is ignored while that entry's label, sentimentMap and transform still merge; an override entry that leaves metaType out keeps the declared one.
  • A layout wrapper's attrs may not set an attribute an identity field emits (data-section, data-media, typeof, property, data-sequence, data-meta-type, or data-{name} for a declared modifier). The attribute is dropped at config assembly and the wrapper's other attributes stand; validateThemeConfig reports it as an error. data-zone-layout and other presentation attributes are unaffected.
  • mergeRuneConfig, derivedAttributeOwner, IDENTITY_FIELD_ATTRIBUTES and derivedAttributeMessage are now exported. Nothing in the shipped plugins or Lumina is affected.
  • @refrakt-md/plan and @refrakt-md/docs now export ./package.json. Their exports maps previously left it out, so require.resolve('<pkg>/package.json') threw ERR_PACKAGE_PATH_NOT_EXPORTED and anything reading the installed package (such as plugin fixture discovery) silently found nothing. @refrakt-md/plan's files also no longer lists a styles directory that does not exist.
  • The plan scanner no longer descends into dot-directories such as .git. Their contents are not plan content, and a lock file git deleted mid-scan could fail the scan with ENOENT.

v0.40.0 — The first composed rune

v0.40.0complete

v0.40.0 — The first composed rune

v0.38.0 and v0.39.0 built the prerequisites: the ownership marker, declarative registration, slot labelling and tree-order preprocess. This milestone uses them to ship the mechanism they were for. A rune defined as a Markdoc template placing other runes (SPEC-145), proved on two real runes from the storytelling plugin, with the packaging groundwork composed runes will need to be delivered (SPEC-153).

The decision pass that settled SPEC-145's blocking questions is done (D2a, D10a–c, D25, D26). What remains open in the spec does not block the two slices below.

Why

Two runes are waiting for it. WORK-617 was cancelled because character, realm and faction could not be expressed in the declared tier. The answer recorded then was "replace them with compositions". Until composition exists, that answer is a promise.

The spec has reached the point where only code can test it. SPEC-145 is a long spec, and its worked examples have been run against today's pipeline by hand. The next useful information comes from building it: whether the markers survive every primitive, whether node-sourced registration holds, and whether the character expansion matches its baseline.

What lands

Eleven work items in four groups, plus one bug rider:

  • Prerequisite (1). WORK-619: the identity guard becomes path-granular (SPEC-158). It is a pure tightening, inert today, and SPEC-158 D7 puts it first.

  • The mechanism (4). Two foundations land inert and gated by "nothing moves":

    • WORK-620 makes a config's block optional;
    • WORK-621 makes data-owner / data-slot survive a primitive's transform.

    Then WORK-622 renders the template, and WORK-623 rejects bad definitions at construction, each by name. WORK-630 adds {% metablock %} (D7). It was pulled in on 2026-10-08, when WORK-622 showed that a composed rune's declared meta blocks cannot render without it and character needs its blocks.metadata.

  • The slices (2), both beside the plugin, not in place of it (SPEC-147 D1):

    • WORK-624 composes bond: attributes, one slot, an edge, no schema;
    • WORK-625 composes character: Person schema, sections with each, and the SEO baseline as the reference.
  • Delivery groundwork (2). WORK-626 makes every plugin export its manifest and publish what files declares. WORK-627 builds the npm pack → install → load harness that catches what monorepo tests cannot.

  • Spike (1). WORK-628 runs @adobe/structured-data-validator over the SEO baseline. It is the evidence for or against lifting D25's ban on schema in user definitions.

  • Rider (1). BUG-032: sandbox's context attribute is never emitted, so every sandbox gets the default design tokens. It is independent and major.

The acceptance test, again

ItemContractsSEO baseline
WORK-619, WORK-620, WORK-621nonenone — each is inert today
WORK-622, WORK-623, WORK-630nonenone — no shipped rune is composed
WORK-624, WORK-625nonenone — the slices ship beside the plugin, and are compared with its baseline in their own fixtures
WORK-626, WORK-627, WORK-628nonenone
BUG-032expected to move; reviewedexpected to move; reviewed

As before, a row that should not move and does has changed behaviour. The slices' deliverable is a written comparison: every difference from the plugin's output is explained, not eliminated (SPEC-147 D2).

Sequencing inside the milestone

  • WORK-620 and WORK-621 → WORK-622 → WORK-623 → WORK-624 → WORK-625, with WORK-630 → WORK-625.
  • WORK-619 is first by SPEC-158 D7 but blocks nothing here, so it can run in parallel with WORK-620/621.
  • WORK-626 → WORK-627.
  • WORK-628 and BUG-032 are independent.

So three tracks can run in parallel: the mechanism, delivery, and the spike plus the rider.

Outcome

Every item is done, and BUG-032 is fixed. PRs #675–#681 and #683–#686.

  • Composition works end to end. bond reproduces the storytelling registry snapshot exactly (#684). character matches the recorded JSON-LD at both harvest points, and its slot-placed portrait now reaches the graph as image (#686). The plugin is unchanged, and each slice's rendered differences are listed and asserted.
  • Three decisions changed on contact:
    • the D10a two-namespace rule for doubly-marked nodes (#680);
    • {% metablock %}, pulled in as WORK-630 when WORK-622 showed declared blocks could not render without it;
    • both worked examples in SPEC-145, rewritten to the definitions that were proved.
  • The D25 spike kept the ban on schema in user definitions. The validator catches typo-class errors, 3 of 17 recorded defects. WORK-629 runs it in CI on first-party rows.
  • Filed along the way:
    • BUG-033 (playlist/track domain errors) and BUG-034 (tokens never reach the sandbox iframe), deferred by decision;
    • WORK-631 (content no field matches is silently dropped);
    • WORK-632 (rows coverage of matches).
  • Recurring local flake: plan-site-dogfood-real and plugins/plan/test/pipeline time out at 30s under full-suite load in 4-core containers. CI is unaffected.

Deliberately not here

Loading definitions from a directory. Plugin.runeDir, runes.dir, project runes through ProjectFiles, precedence and dev invalidation are SPEC-153's steps 3–5. Here a first-party definition reaches the pipeline as a PluginRune carrying a template. That also means D25's user-definition ban has no loader to enforce it yet, and needs none, because no user path exists.

The rest of SPEC-145's surface. None of these is needed by bond or character:

  • multiple templates (D15)
  • the chrome carrier (D14, D24)
  • the i18n keying (D13)
  • editor affordances (D11's editor half)
  • $slots conditionals
  • refrakt inspect showing the expansion

They stay open in the spec for the milestone that composes a rune which needs them.

The rest of SPEC-147. realm, faction and lore follow once character has shown the pattern. Also deferred: plot's sequence extension, the segmented content model, core entity auto-linking, and storyboard's relocation. Retiring the plugin is not in scope at all (SPEC-147 D1).

SPEC-156, SPEC-159, SPEC-142, SPEC-144's sentinel resolution, SPEC-139, and the v1.0 re-scope. None of them is a prerequisite for the first composed rune.

Composed runes

SPEC-145accepted

Composed runes

runescompositionmarkdocseohostedarchitecture

Summary

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

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

Background — why this is the pressure valve

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

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

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

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

The mechanism mostly exists

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

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

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

Three things are missing.

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

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

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

SEO — the retyping works, the name resolution does not

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

What does fit — retyping

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

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

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

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

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

What does not fit — reaching the values

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

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

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

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

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

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

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

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

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

The identity guard is path-granular

SPEC-158shipped

The identity guard is path-granular

themeconfigidentitycorrectnessarchitecture

Summary

Refining the composition corpus surfaced four cross-document tensions. Three of them are one question: ADR-028's identity guard is key-granular, and two of the things that need protecting are not keys. The fourth is a hole in the same guard, measured rather than argued.

ADR-030 predicted this in its own Consequences and left it open:

Semantic locking needs somewhere to live. Rule 3 requires the engine to distinguish rune-owned from theme-owned modifiers on the same arrangement… Modifiers need the same treatment, and the two should use one mechanism.

This is that mechanism. It is deliberately small and it is a prerequisite rather than a feature: both tightenings are inert against today's corpus, measured below, so they can land before anything that depends on them.

The guard as it stands

mergeRuneConfig calls findReservedFields(override, IDENTITY_FIELDS) (packages/transform/src/merge.ts:180), and the test is key presence. The reason is recorded on the function itself:

Presence is what counts — an explicit sections: undefined still shadows the base under a spread merge, so in is the right test.

IDENTITY_FIELDS is eight top-level keys: block, modifiers, sections, mediaSlots, frameTarget, universalAttributes, provides, schema. A violation is dropped and reported, not thrown.

And the reach already exists for merging but not for guarding: merge.ts walks into override.blocks (:145) and override.variants (:163) to merge them per-entry. So the machinery to address a path is present; only the guard is flat.

The three cases

1. sequence is a whole key, and it is simply missing

RuneConfig.sequence is 'numbered' | 'connected' | 'plain' (packages/transform/src/types.ts:456) and is not in IDENTITY_FIELDS. So a theme override can flip a rune from numbered to connected today.

That is ADR-030 rule 3's founding example, and its reasoning is the clearest statement of the principle anywhere in the corpus:

On a playlist the number is the track number, an identifier a listener says out loud. Restyling a playlist as a timeline does not change how it looks. It deletes the track numbers.

So numbered vs connected is not an appearance cut. It encodes "is the ordinal itself information?" — a fact about the content, not about the theme.

sequenceDirection is the opposite and stays presentational: rule 3 assigns "spacing, and responsive direction collapse" to the theme explicitly.

2. metaFields[*].metaType is a sub-key

metaType does two jobs. It selects render shape — chip for status / category / tag, bare for id / quantity / temporal / code — and its own interface comment describes the manifest as "the field's domain semantics (type, sentiment, label)".

The first job is the theme's. The second is not: a theme retyping status from status to category is not restyling a field, it is changing what the author declared their data to mean. SPEC-152 D4 is where this stops being theoretical — a composed plan entity's entire differentiation from its four siblings is its metaFields and blocks manifest, because the layout is byte-identical five ways.

So the field splits, and the precedent for splitting within a concern is established twice: sections is identity while blocks and layout are presentation (SPEC-080 / SPEC-081), and rule 3 splits the marker from the marker's chrome. label is presentational and additionally i18n-keyed (SPEC-035); sentimentMap and transform are presentational.

3. layout.attrs can write what an identity field derives

LayoutEntry.attrs is spread onto a created wrapper unconditionally at packages/transform/src/engine.ts:1532:

out.push(makeTag(entry.tag, { 'data-name': name, ...(entry.attrs ?? {}) }, kids));

Nothing validates it — not at config normalisation, not at refrakt plugin validate. SPEC-156 measured the arrangement case (data-zone-layout lands and takes effect). The problem is the general one: layout is presentation and correctly theme-overridable, and an arbitrary attribute map inside it goes around every guard that works by owning a field's source. data-section comes from sections; typeof and property come from schema; both keys are guarded and neither attribute is.

Both tightenings are inert today

Measured, because a prophylactic that breaks the reference theme is not cheap:

  • packages/lumina/src/config.ts is 106 lines and contains no sequence, metaFields or metaType entry. The reference theme overrides none of them.
  • The five plugin configs that mention sequence (places, business, media, marketing, learning) declare it for their own runes, which is the base rather than an override.

So this lands as a no-op against the current corpus and starts paying only when a theme or a composed rune tries what the audits identified.

Decisions

D1 — the guard is path-granular, not key-granular

IDENTITY_FIELDS becomes a list of config paths — a bare key where a whole key is protected, and a path with a wildcard segment where a sub-field is (metaFields.*.metaType). findReservedFields resolves a path rather than testing in. The presence semantics that motivated in are preserved per segment: an explicit undefined at a protected path is still a violation, because it still shadows the base under a spread merge.

D2 — sequence is identity; sequenceDirection is not

Rule 3, applied. One encodes whether the ordinal is information; the other is responsive collapse, which rule 3 hands the theme in as many words.

D3 — metaFields.*.metaType is identity; label, sentimentMap and transform are not

The same split sections / blocks already draws, one level down. A theme restyles how a field is presented and may not restate what kind of field it is.

D4 — an attrs map may not set an attribute that an identity field derives

Stated by inversion rather than as a deny-list, so it cannot drift: each identity field has a known set of emitted attributes, and those are refused in attrs. Enumerating attribute names instead would go stale the first time a field gains an output.

data-zone-layout is not refused. SPEC-156 D5 legitimises the arrangement use, and an arrangement is presentation — the point is that it becomes a declaration rather than staying an attribute-map side effect.

D5 — a violation is dropped and reported, never thrown

Unchanged from today's behaviour, and extended to the new granularity: a guarded sub-field is dropped while the rest of its entry merges, and a refused attribute is dropped while the rest of the wrapper stands. A theme should not be able to fail a build, and a partially-applied override that reports itself is more useful than an abort.

D6 — this closes ADR-030's open "semantic locking needs somewhere to live"

Recorded so the arrangement work does not reopen it. One mechanism serves both the arrangement modifiers rule 3 describes and the metadata manifest SPEC-152 D4 needs, which is what that consequence asked for.

D7 — this ships before the work that depends on it

It is inert against the current corpus (measured above), it unblocks ADR-030 and SPEC-152 D4, and it is the only item among the four tensions that is purely a tightening. Sequencing it first means the specs that cite these rules stop citing something the code does not enforce.

Non-goals

  • Changing what a theme may override beyond the three cases above; this is a granularity change, not a re-litigation of ADR-028
  • The postProcess declarability question — a different axis, resolved in SPEC-144 D2
  • Specifying the arrangement vocabulary or its declaration (SPEC-156)
  • Adding validation to refrakt plugin validate beyond the attrs refusal
  • Revisiting drop-and-report in favour of throwing (D5)

Acceptance Criteria

  • IDENTITY_FIELDS holds paths, and findReservedFields resolves a wildcard segment, with the existing eight keys behaving exactly as before — asserted by the current tests passing unchanged
  • A theme override setting sequence is dropped and reported, with the rune and field named, and a test cites ADR-030 rule 3 as the reason so the history is not lost
  • sequenceDirection remains overridable, asserted alongside, so the split is visible in one place
  • A theme override setting metaFields.status.metaType is dropped while that field's label and sentimentMap merge normally, asserted on one override carrying all three
  • An attrs map setting data-section, typeof or property is refused with the path and attribute named; the wrapper's other attributes still apply
  • attrs setting data-zone-layout is not refused (D4)
  • The refusal is enforced at config normalisation, so it fires once per config rather than per render
  • npm run seo:baseline:check and refrakt contracts --check report no drift on either contract copy — the change is inert against the current corpus, and a diff means it is not
  • packages/lumina merges unchanged, asserted rather than assumed, since the inertness claim rests on it
  • The theme-authoring guide states which paths are guarded and why, with the track-number example as the case that makes it concrete

References

  • ADR-028 — a theme restructures a rune, never redefines it; the rule whose enforcement this sharpens
  • ADR-030 — arrangements; rule 3's semantic locking, its founding example, and the open consequence D6 closes
  • SPEC-152 — the plan audit; D4 is where metaFields' identity status stops being theoretical
  • SPEC-143 — where the metaFields question was first recorded as open
  • SPEC-156 — the ladder and row arrangements; measured the attrs hole and legitimises its arrangement use
  • SPEC-080 — metaFields / blocks; the manifest whose sub-field D3 splits
  • SPEC-081 — layout projection and the { tag, children, attrs } creating form
  • SPEC-035 — i18n keying, which is why label is presentational