WORK-571
ID:WORK-571Status:done

Synthesised entities and index-derived values

The last two Group C shapes. testimonial and event build entity spans by hand from text pulled out of other tags; breadcrumb and timeline emit positions from a loop index. Both become declarations.

Closes Group C, and with it the migration.

Priority:mediumComplexity:moderateMilestone:v0.35.0Source:SPEC-130
claude/spec-131-snippet-targeting-q5ra9y View source

Criteria completion

Criteria completion: 9 of 9 (100%) checked; tracking started on Sep 17, no incremental history yet0%25%50%75%100%Sep 17Oct 11

Tracking started Sep 17 — check back for trends.

Branches 4
History 6
  1. 5ada616
    Created (done)by bjornolofandersson
  2. d472efc
    Content editedby Claude
    docs(plan): link WORK-569, WORK-570 and WORK-571 to their PR
  3. d123b67
    Content editedby Claude
    refactor(runes): declare accordion, recipe and how-to's retype-and-wrap
  4. 11e7ead
    Content editedby Claude
    docs(plan): link WORK-567, WORK-568 and WORK-571 to their PR
  5. 846d2d4
    Content editedby Claude
    feat(runes)!: generate breadcrumb and timeline positions from a declared
  6. f847949
    Content editedby Claude
    docs(plan): break SPEC-130 into the v0.35.0 milestone

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 accident
  • breadcrumb and timeline declare position: 'index' instead of emitting positions from the loop
  • buildAutoBreadcrumb'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.

Blocked by

  • WORK-563
  • WORK-565

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.