Synthesised entities — already prototyped
testimonial builds a <span typeof="Person"> from authorNameTag.children.filter(…); event builds a Place from attrs.location. Naming the invented span is pointless, because under the table the applier is what creates it. What matters is that the sources are addressable, and they already are.
export const testimonialSchema = {
type: 'Review',
properties: { quote: 'reviewBody' },
entities: {
author: { type: 'Person', property: 'author',
properties: { 'author-name': 'name', 'author-role': 'jobTitle' } },
rating: { type: 'Rating', property: 'reviewRating',
properties: { rating: 'ratingValue' } },
},
} as const;
SPEC-130's prototype converted both: testimonial reproduced its JSON-LD byte-identical, event identical under the normalised comparison, differing only in key order because rebuilt carriers are appended last. So this half is the best-understood work in the milestone — it is the case that settled the mechanism's design.
testimonial's rating is the one to watch. Its meta is dropped by createComponentRenderable once the rune stops declaring schema:, and the applier recovers the value from data-rune-fields. That path is exercised here for real rather than in a prototype.
Index-derived values — one keyword, and a live hole
position: 'index'. A value that exists nowhere in the content has to be generated, and the vocabulary is closed: index is the only generator anywhere in the codebase, used by exactly two runes.
breadcrumb is the harder one, and not because of the index. It is built by buildAutoBreadcrumb from a postProcess hook, not from a Markdoc schema, so the createContentModelSchema wrapper cannot reach it. D8: the hook calls the applier directly. One call site, and it keeps "not half of each" exceptionless rather than carving out the one emitter that happens to live in a pipeline phase.
That only means anything because WORK-563 moved the harvest — before it, {% breadcrumb auto=true %} contributed nothing to the JSON-LD no matter what the hook emitted.
Acceptance Criteria
testimonial and event declare entities: tables and stop synthesising entity spans in their transforms- Both reproduce WORK-562's baseline —
testimonial exactly, event under the normalised comparison testimonial's existing jobTitle: ", CTO at Acme" comma defect is either preserved deliberately or fixed deliberately, and the choice is stated — it must not change by accidentbreadcrumb and timeline declare position: 'index' instead of emitting positions from the loopbuildAutoBreadcrumb's postProcess hook applies its resolved row through the same applier, not a hand-written mapping (D8)breadcrumb emits String(index + 1) so the RDFa and the JSON-LD agree on position — a string in one and a number in the other is a drift the invariant would otherwise have to tolerate (D8)- The page-level two-point invariant passes for a page using
{% breadcrumb auto=true %} event's location resolves by name rather than being duplicated into a Place span by hand- After this item, no rune passes
schemaOrgType or a schema: map to createComponentRenderable, and no transform mutates attributes.typeof — the imperative form is fully migrated, not half of each
Approach
The last criterion is the milestone's closing condition, and it is worth checking mechanically rather than by inspection: a test that walks the catalog and fails on any surviving schemaOrgType:, schema: or attributes.typeof = … is cheap and makes "fully migrated" a fact rather than a claim.
Do the two shapes in either order — they are independent — but breadcrumb last, since it is the one that spans the pipeline and is easiest to verify once everything else is quiet.
timeline and timeline-entry split across items (WORK-568 has the entry), and so do breadcrumb and breadcrumb-item. A child entity is never declared without the property that holds it, so each pair has to end up coherent even though it lands in two commits. Land the parent second and check the pair together.
References
- SPEC-130 — "Prototype: the
entities: shape, measured", Group C shapes 2 and 3, D8 - WORK-563 — the harvest move that makes the
postProcess hook's output reachable packages/runes/src/config.ts — buildAutoBreadcrumb
Resolution
Completed: 2026-09-16
Branch: claude/v0.35-parallel-feasibility-eia5le
What was done
Synthesised entities — testimonial and event were already converted with the mechanism in WORK-565, so this item verified them rather than moving them. Both tables are in place, event's location resolves by name, and both reproduce the baseline.
testimonial's jobTitle: ", CTO at Acme" comma defect is preserved deliberately, filed as BUG-019. The fault is in the attribution parser, not the mapping — testimonialSchema reads author-role faithfully; the text handed to it is what is wrong — and fixing it changes rendered HTML on every comma-form testimonial. That is a separate user-visible change and should not ride inside a migration whose entire evidence is "the baseline diff shows only what was intended".
Index-derived values — breadcrumbSchema and timelineSchema declare generated: { position: 'index' }, replacing the loops that pushed a <meta property="position"> into each child. breadcrumb and breadcrumb-item moved together, which also closed WORK-568's fourteenth rune: a position exists nowhere in the content, and only the parent knows a child's index.
D8 — buildAutoBreadcrumb calls applySchemaTable directly. It runs from a postProcess hook, so createContentModelSchema's wrapper cannot reach it. The payoff beyond exceptionlessness is that both breadcrumb paths now publish from the same table instead of a declaration plus a hand-written copy of it.
The closing condition became a test. packages/runes/test/imperative-schema-migration.test.ts reads every rune source in the catalog and counts the three imperative spellings (schemaOrgType:, a schema:/typeof: map passed to createComponentRenderable, and attributes.typeof =). PENDING names the four files WORK-569 and WORK-570 own. It fails both ways — a migrated rune left in the list fails as loudly as a reintroduced imperative form — and it self-tests its matcher, since a guard whose regex silently stops matching passes forever.
Notes
position is now a string in the published JSON-LD, which is the point: String(index + 1) is what the RDFa attribute carries, so the two channels finally agree. Four tests pinned the number and were updated with the reason.itemListElement is a declared list, so breadcrumb.single, timeline.single and the truncated trail in the BUG-018 test emit arrays. Those three fixtures moved from pending to declared in the baseline's D6 guard.- Baseline diff is exactly those two shapes and nothing else.
- The last acceptance criterion — no rune anywhere passes
schemaOrgType or a schema: map, and no transform mutates attributes.typeof — is the milestone's closing condition and stays open until WORK-569 and WORK-570 land. The guard above is what will confirm it, mechanically, rather than by inspection.